diff --git a/.editorconfig b/.editorconfig index 1148706b69..7b307437a7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -137,6 +137,7 @@ dotnet_style_readonly_field = true:error # CSharp code style settings: [*.cs] +end_of_line = lf # Prefer "var" everywhere csharp_style_var_for_built_in_types = true:error csharp_style_var_when_type_is_apparent = true:error diff --git a/aspire/AppHost.cs b/aspire/AppHost.cs index ca79ebc317..34253c402f 100644 --- a/aspire/AppHost.cs +++ b/aspire/AppHost.cs @@ -33,7 +33,8 @@ internal static async Task Run( bool assumeCloned = false, bool assumeBuild = false, bool skipPrivateRepositories = false, - CancellationToken ct = default) + CancellationToken ct = default + ) { var builder = DistributedApplication.CreateBuilder(); @@ -49,15 +50,14 @@ internal static async Task Run( var buildAll = builder.AddProject(AssemblerBuild); string[] buildArgs = assumeBuild ? ["--assume-build"] : []; - buildAll = buildAll - .WithArgs(["assembler", "build", .. GlobalArguments, .. buildArgs]) - .WaitForCompletion(cloneAll) - .WithParentRelationship(cloneAll); + buildAll = + buildAll.WithArgs(["assembler", "build", .. GlobalArguments, .. buildArgs]) + .WaitForCompletion(cloneAll) + .WithParentRelationship(cloneAll); IResourceBuilder? elasticsearchLocal = null; if (startElasticsearch) - elasticsearchLocal = builder.AddElasticsearch(ElasticsearchLocal) - .WithEnvironment("LICENSE", "trial"); + elasticsearchLocal = builder.AddElasticsearch(ElasticsearchLocal).WithEnvironment("LICENSE", "trial"); var elasticsearchRemote = builder.AddExternalService(ElasticsearchRemote, elasticsearchUrl); @@ -78,10 +78,12 @@ internal static async Task Run( // ReSharper disable once RedundantAssignment api = startElasticsearch - ? api - .WithReference(elasticsearchLocal!) + ? api.WithReference(elasticsearchLocal!) .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal!.GetEndpoint("http")) - .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal!.Resource.PasswordParameter) + .WithEnvironment( + context => + context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal!.Resource.PasswordParameter + ) .WithParentRelationship(elasticsearchLocal!) .WaitFor(elasticsearchLocal!) : api.WithReference(elasticsearchRemote) @@ -96,10 +98,12 @@ internal static async Task Run( // ReSharper disable once RedundantAssignment mcp = startElasticsearch - ? mcp - .WithReference(elasticsearchLocal!) + ? mcp.WithReference(elasticsearchLocal!) .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal!.GetEndpoint("http")) - .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal!.Resource.PasswordParameter) + .WithEnvironment( + context => + context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal!.Resource.PasswordParameter + ) .WithParentRelationship(elasticsearchLocal!) .WaitFor(elasticsearchLocal!) : mcp.WithReference(elasticsearchRemote) @@ -113,14 +117,15 @@ internal static async Task Run( // ReSharper disable once RedundantAssignment indexElasticsearch = startElasticsearch - ? indexElasticsearch - .WaitFor(elasticsearchLocal!) + ? indexElasticsearch.WaitFor(elasticsearchLocal!) .WithReference(elasticsearchLocal!) .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal!.GetEndpoint("http")) - .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal!.Resource.PasswordParameter) + .WithEnvironment( + context => + context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal!.Resource.PasswordParameter + ) .WithParentRelationship(elasticsearchLocal!) - : indexElasticsearch - .WithReference(elasticsearchRemote) + : indexElasticsearch.WithReference(elasticsearchRemote) .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchUrl) .WithEnvironment("DOCUMENTATION_ELASTIC_APIKEY", elasticsearchApiKey) .WithParentRelationship(elasticsearchRemote); @@ -135,12 +140,13 @@ internal static async Task Run( .WithParentRelationship(cloneAll); serveStatic = startElasticsearch - ? serveStatic - .WithReference(elasticsearchLocal!) + ? serveStatic.WithReference(elasticsearchLocal!) .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchLocal!.GetEndpoint("http")) - .WithEnvironment(context => context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal!.Resource.PasswordParameter) - : serveStatic - .WithReference(elasticsearchRemote) + .WithEnvironment( + context => + context.EnvironmentVariables["DOCUMENTATION_ELASTIC_PASSWORD"] = elasticsearchLocal!.Resource.PasswordParameter + ) + : serveStatic.WithReference(elasticsearchRemote) .WithEnvironment("DOCUMENTATION_ELASTIC_URL", elasticsearchUrl) .WithEnvironment("DOCUMENTATION_ELASTIC_APIKEY", elasticsearchApiKey); diff --git a/build/Program.cs b/build/Program.cs index aaaf532e1e..1bbf8cfe57 100644 --- a/build/Program.cs +++ b/build/Program.cs @@ -11,99 +11,110 @@ var app = ConsoleApp.Create(); -app.Add("", async Task (Cancel _) => -{ - await "dotnet tool restore"; - await "dotnet build -c Release --verbosity minimal"; - await "dotnet test --configuration Release --logger GitHubActions -- RunConfiguration.CollectSourceInformation=true"; -}); +app.Add( + "", + async Task (Cancel _) => + { + await "dotnet tool restore"; + await "dotnet build -c Release --verbosity minimal"; + await "dotnet test --configuration Release --logger GitHubActions -- RunConfiguration.CollectSourceInformation=true"; + } +); // this is manual for now and quite hacky. // this ensures we download the actual LICENSE files in the repositories. // NOT the SPDX html from licenses.nuget.org -app.Add("notices", async Task (Cancel ctx) => -{ - var packages = await "dotnet thirdlicense --project src/docs-builder/docs-builder.csproj --output NOTICE.txt"; - var packageLines = packages.Split(Environment.NewLine).Where(l => l.StartsWith("+")); +app.Add( + "notices", + async Task (Cancel ctx) => + { + var packages = await "dotnet thirdlicense --project src/docs-builder/docs-builder.csproj --output NOTICE.txt"; + var packageLines = packages.Split(Environment.NewLine).Where(l => l.StartsWith("+")); - await File.WriteAllTextAsync("NOTICE.txt", - $""" + await File.WriteAllTextAsync( + "NOTICE.txt", + $""" Elastic Documentation Tooling Copyright 2024-{DateTime.UtcNow.Year} Elasticsearch B.V. - """, ctx); - + """, + ctx + ); - Console.WriteLine("Package lines:"); - foreach (var line in packageLines) - { - var package = line.Split('+', '(')[1].ToLowerInvariant().Trim(); - var version = line.Split('(', ')')[1].TrimStart('v').Trim(); - if (package.StartsWith("microsoft.") || package.StartsWith("system")) - continue; - - var text = await fetchText($"https://api.nuget.org/v3-flatcontainer/{package}/{version}/{package}.nuspec"); - var xml = XDocument.Load(new StringReader(text)); - var projectUrl = xml.XPathSelectElement("//*[local-name()='projectUrl']")?.Value; - var id = xml.XPathSelectElement("//*[local-name()='id']")?.Value; - projectUrl = projectUrl?.Replace("/wiki", string.Empty); - - if (projectUrl is null || projectUrl.Contains(".github.com")) - throw new Exception($"Can not download license for {id}: {projectUrl}"); - - var rawUrl = projectUrl.Replace("github.com", "raw.githubusercontent.com"); - string[] targets = - [ - rawUrl + $"/refs/heads/master/" + "LICENSE.txt", - rawUrl + $"/refs/heads/master/" + "license.txt", - rawUrl + $"/refs/heads/master/" + "LICENSE", - rawUrl + $"/refs/heads/master/" + "LICENSE.md", - rawUrl + $"/refs/heads/main/" + "LICENSE.txt", - rawUrl + $"/refs/heads/main/" + "license.txt", - rawUrl + $"/refs/heads/main/" + "LICENSE", - rawUrl + $"/refs/heads/main/" + "LICENSE.md", - ]; - var license = string.Empty; - foreach (var target in targets) + Console.WriteLine("Package lines:"); + foreach (var line in packageLines) { - Console.WriteLine($"Downloading license for {id}: {target}"); - try + var package = line.Split('+', '(')[1].ToLowerInvariant().Trim(); + var version = line.Split('(', ')')[1].TrimStart('v').Trim(); + if (package.StartsWith("microsoft.") || package.StartsWith("system")) + continue; + + var text = await fetchText($"https://api.nuget.org/v3-flatcontainer/{package}/{version}/{package}.nuspec"); + var xml = XDocument.Load(new StringReader(text)); + var projectUrl = xml.XPathSelectElement("//*[local-name()='projectUrl']")?.Value; + var id = xml.XPathSelectElement("//*[local-name()='id']")?.Value; + projectUrl = projectUrl?.Replace("/wiki", string.Empty); + + if (projectUrl is null || projectUrl.Contains(".github.com")) + throw new Exception($"Can not download license for {id}: {projectUrl}"); + + var rawUrl = projectUrl.Replace("github.com", "raw.githubusercontent.com"); + string[] targets = + [ + rawUrl + $"/refs/heads/master/" + "LICENSE.txt", + rawUrl + $"/refs/heads/master/" + "license.txt", + rawUrl + $"/refs/heads/master/" + "LICENSE", + rawUrl + $"/refs/heads/master/" + "LICENSE.md", + rawUrl + $"/refs/heads/main/" + "LICENSE.txt", + rawUrl + $"/refs/heads/main/" + "license.txt", + rawUrl + $"/refs/heads/main/" + "LICENSE", + rawUrl + $"/refs/heads/main/" + "LICENSE.md", + ]; + var license = string.Empty; + foreach (var target in targets) { - license = await fetchText(target); + Console.WriteLine($"Downloading license for {id}: {target}"); + try + { + license = await fetchText(target); + } + catch { } + if (license.Length > 0) + break; } - catch { } - if (license.Length > 0) - break; - } - if (string.IsNullOrWhiteSpace(license)) - throw new Exception($"Can not download license for {id}: {projectUrl}"); + if (string.IsNullOrWhiteSpace(license)) + throw new Exception($"Can not download license for {id}: {projectUrl}"); - await File.AppendAllTextAsync("NOTICE.txt", - $""" + await File.AppendAllTextAsync( + "NOTICE.txt", + $""" License notice for {id} (v{version}) ------------------------------------ {license} - """, ctx); - } + """, + ctx + ); + } - try - { - await "git status --porcelain"; - } - catch (Exception ex) - { - Console.WriteLine(ex.ToString()); - Console.WriteLine("The build left unchecked artifacts in the source folder"); - await "git diff NOTICE.txt"; - return 1; - } + try + { + await "git status --porcelain"; + } + catch (Exception ex) + { + Console.WriteLine(ex.ToString()); + Console.WriteLine("The build left unchecked artifacts in the source folder"); + await "git diff NOTICE.txt"; + return 1; + } - return 0; -}); + return 0; + } +); await app.RunAsync(args); diff --git a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.cs b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.cs index 370845aea7..92e7fd745a 100644 --- a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.cs +++ b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.cs @@ -33,8 +33,14 @@ public class ApiPropertyTreeBuilder(OpenApiDocument document, PropertyDisplayOpt /// One renderable property before its display fields are derived. private sealed record PropertyRow( - string Name, IOpenApiSchema Schema, TypeInfo TypeInfo, string AnchorId, - bool IsRequired, bool IsLast, bool IsRecursive); + string Name, + IOpenApiSchema Schema, + TypeInfo TypeInfo, + string AnchorId, + bool IsRequired, + bool IsLast, + bool IsRecursive + ); /// Builds the property rows for a schema; null when it has no renderable properties. public ApiPropertyList? BuildPropertyList(IOpenApiSchema? schema, PropertyTreeScope scope) @@ -55,10 +61,14 @@ private sealed record PropertyRow( var typeInfo = _analyzer.GetTypeInfo(propSchema); var propId = string.IsNullOrEmpty(scope.Prefix) ? name : $"{scope.Prefix}-{name}"; var row = new PropertyRow( - name, propSchema, typeInfo, propId, + name, + propSchema, + typeInfo, + propId, IsRequired: requiredProps.Contains(name), IsLast: i == propArray.Length - 1, - IsRecursive: DetectRecursion(propSchema, typeInfo, scope.Ancestors)); + IsRecursive: DetectRecursion(propSchema, typeInfo, scope.Ancestors) + ); items.Add(BuildProperty(row, scope)); } @@ -66,14 +76,20 @@ private sealed record PropertyRow( } /// Builds the expanded variants for a top-level oneOf/anyOf union (schema pages). - public ApiUnionVariants? BuildUnionVariantsForSchemas(IList unionSchemas, string prefix, IReadOnlySet? ancestors) + public ApiUnionVariants? BuildUnionVariantsForSchemas( + IList unionSchemas, + string prefix, + IReadOnlySet? ancestors + ) { - var unionOptions = unionSchemas.Where(s => s is not null).Select(s => - { - var info = _analyzer.GetTypeInfo(s); - var displayName = info.IsArray ? $"{info.TypeName}[]" : info.TypeName; - return new UnionOption(displayName, info.SchemaRef, info.IsObject, s); - }).ToList(); + var unionOptions = unionSchemas.Where(s => s is not null) + .Select(s => + { + var info = _analyzer.GetTypeInfo(s); + var displayName = info.IsArray ? $"{info.TypeName}[]" : info.TypeName; + return new UnionOption(displayName, info.SchemaRef, info.IsObject, s); + }) + .ToList(); return BuildUnionVariants(unionOptions, new PropertyTreeScope { Prefix = prefix, Ancestors = ancestors }); } @@ -121,8 +137,7 @@ public static IReadOnlyList BuildConstraints(IOpenApiSchema s return constraints; } - private bool HasActualProperties(IOpenApiSchema? schema) => - _analyzer.GetSchemaProperties(schema)?.Count > 0; + private bool HasActualProperties(IOpenApiSchema? schema) => _analyzer.GetSchemaProperties(schema)?.Count > 0; private ApiProperty BuildProperty(PropertyRow row, PropertyTreeScope scope) { @@ -144,9 +159,7 @@ private ApiProperty BuildProperty(PropertyRow row, PropertyTreeScope scope) ? HtmlString.Empty : options.RenderMarkdown(propSchema.Description), ShowDeprecatedBadge = options.ShowDeprecated && propSchema.Deprecated, - Availability = options.ShowVersionInfo - ? AvailabilityBadgeHelper.FromSchema(propSchema, options.VersionsConfiguration) - : null, + Availability = options.ShowVersionInfo ? AvailabilityBadgeHelper.FromSchema(propSchema, options.VersionsConfiguration) : null, ExternalDocs = BuildExternalDocs(propSchema, typeInfo), Constraints = BuildConstraints(propSchema), EnumValues = typeInfo is { IsEnum: true, EnumValues.Length: > 0 } ? typeInfo.EnumValues : [], @@ -156,33 +169,46 @@ private ApiProperty BuildProperty(PropertyRow row, PropertyTreeScope scope) IsCollapsible = expansion.IsCollapsible, DefaultExpanded = expansion.DefaultExpanded, NestedCount = expansion.NestedCount, - Children = isRecursive - ? ApiPropertyChildren.None - : BuildChildren(row, scope, expansion) + Children = isRecursive ? ApiPropertyChildren.None : BuildChildren(row, scope, expansion) }; } /// Everything the original view's opening code block derived about a property's expansion. private sealed record Expansion( - bool HasNestedProps, bool HasDictValueProps, IOpenApiSchema? ArrayItemSchema, bool HasArrayItemProps, - bool IsSimpleArrayUnion, string? SimpleUnionBaseName, bool HasUnionOptions, - bool SimpleUnionHasExpandableProps, IOpenApiSchema? SimpleUnionSchema, List? SimpleUnionNestedOptions, - int NestedCount, bool HasChildren, bool IsCollapsible, bool DefaultExpanded); + bool HasNestedProps, + bool HasDictValueProps, + IOpenApiSchema? ArrayItemSchema, + bool HasArrayItemProps, + bool IsSimpleArrayUnion, + string? SimpleUnionBaseName, + bool HasUnionOptions, + bool SimpleUnionHasExpandableProps, + IOpenApiSchema? SimpleUnionSchema, + List? SimpleUnionNestedOptions, + int NestedCount, + bool HasChildren, + bool IsCollapsible, + bool DefaultExpanded + ); private Expansion ComputeExpansion(IOpenApiSchema propSchema, TypeInfo typeInfo, int depth, bool isRecursive) { var dictHasLinkedValue = typeInfo is { IsDictionary: true, HasLink: true }; - var hasNestedProps = typeInfo is { IsObject: true, HasLink: false } && depth < options.MaxDepth - && HasActualProperties(propSchema); + var hasNestedProps = typeInfo is { IsObject: true, HasLink: false } && depth < options.MaxDepth && HasActualProperties(propSchema); var hasDictValueProps = typeInfo is { IsDictionary: true, DictValueSchema: not null } - && depth < options.MaxDepth && !dictHasLinkedValue && HasActualProperties(typeInfo.DictValueSchema); + && depth < options.MaxDepth + && !dictHasLinkedValue + && HasActualProperties(typeInfo.DictValueSchema); var arrayItemSchema = typeInfo.IsArray && propSchema.Items is not null ? propSchema.Items : null; - var hasArrayItemProps = arrayItemSchema is not null && !typeInfo.HasLink && depth < options.MaxDepth + var hasArrayItemProps = arrayItemSchema is not null + && !typeInfo.HasLink + && depth < options.MaxDepth && HasActualProperties(arrayItemSchema); var (isSimpleArrayUnion, simpleUnionBaseName) = DetectSimpleArrayUnion(typeInfo); - var hasUnionOptions = typeInfo is { IsUnion: true, AnyOfOptions: not null } && depth < options.MaxDepth + var hasUnionOptions = typeInfo is { IsUnion: true, AnyOfOptions: not null } + && depth < options.MaxDepth && !isSimpleArrayUnion && typeInfo.AnyOfOptions.Any(_analyzer.UnionOptionHasProperties); @@ -203,15 +229,27 @@ private Expansion ComputeExpansion(IOpenApiSchema propSchema, TypeInfo typeInfo, else if (simpleUnionHasExpandableProps && simpleUnionSchema is not null) nestedCount = _analyzer.GetSchemaProperties(simpleUnionSchema)?.Count ?? 0; - var hasChildren = (hasNestedProps || hasDictValueProps || hasArrayItemProps || hasUnionOptions || simpleUnionHasExpandableProps) && !isRecursive; + var hasChildren = (hasNestedProps || hasDictValueProps || hasArrayItemProps || hasUnionOptions || simpleUnionHasExpandableProps) && + !isRecursive; var isCollapsible = hasChildren && nestedCount > 1 && !hasUnionOptions && !hasDictValueProps; var defaultExpanded = ComputeDefaultExpanded(depth, nestedCount); return new Expansion( - hasNestedProps, hasDictValueProps, arrayItemSchema, hasArrayItemProps, - isSimpleArrayUnion, simpleUnionBaseName, hasUnionOptions, - simpleUnionHasExpandableProps, simpleUnionSchema, simpleUnionNestedOptions, - nestedCount, hasChildren, isCollapsible, defaultExpanded); + hasNestedProps, + hasDictValueProps, + arrayItemSchema, + hasArrayItemProps, + isSimpleArrayUnion, + simpleUnionBaseName, + hasUnionOptions, + simpleUnionHasExpandableProps, + simpleUnionSchema, + simpleUnionNestedOptions, + nestedCount, + hasChildren, + isCollapsible, + defaultExpanded + ); } private static (bool IsSimpleArrayUnion, string? BaseName) DetectSimpleArrayUnion(TypeInfo typeInfo) @@ -234,7 +272,11 @@ private static (bool IsSimpleArrayUnion, string? BaseName) DetectSimpleArrayUnio } private (bool Expandable, IOpenApiSchema? Schema, List? NestedOptions) ResolveSimpleUnionExpansion( - TypeInfo typeInfo, bool isSimpleArrayUnion, string? simpleUnionBaseName, int depth) + TypeInfo typeInfo, + bool isSimpleArrayUnion, + string? simpleUnionBaseName, + int depth + ) { if (!isSimpleArrayUnion || string.IsNullOrEmpty(simpleUnionBaseName) || depth >= options.MaxDepth) return (false, null, null); @@ -248,9 +290,7 @@ private static (bool IsSimpleArrayUnion, string? BaseName) DetectSimpleArrayUnio return (false, null, null); var directProps = _analyzer.GetSchemaProperties(baseOption.Schema); - var nestedOptions = directProps is null or { Count: 0 } - ? _analyzer.GetNestedUnionOptions(baseOption.Schema) - : null; + var nestedOptions = directProps is null or { Count: 0 } ? _analyzer.GetNestedUnionOptions(baseOption.Schema) : null; return (true, baseOption.Schema, nestedOptions); } @@ -265,8 +305,7 @@ private bool ComputeDefaultExpanded(int depth, int nestedCount) => return new ExternalDocLink(url, IsElasticDocsUrl(url)); } - internal static bool IsElasticDocsUrl(string url) => - url.Contains("www.elastic.co/docs") || url.Contains("elastic.co/guide"); + internal static bool IsElasticDocsUrl(string url) => url.Contains("www.elastic.co/docs") || url.Contains("elastic.co/guide"); private TypePageLink? BuildTypeLink(TypeInfo typeInfo, Expansion expansion) { @@ -296,15 +335,14 @@ internal static bool IsElasticDocsUrl(string url) => unionOptionNames.AddRange(typeInfo.AnyOfOptions.Select(o => o.Name)); if (typeInfo.UnionOptions is not null) unionOptionNames.AddRange(typeInfo.UnionOptions); - var sortedOptions = unionOptionNames.Distinct() - .OrderByDescending(o => o.EndsWith("[]")) - .ToArray(); + var sortedOptions = unionOptionNames.Distinct().OrderByDescending(o => o.EndsWith("[]")).ToArray(); - var allEnumLike = sortedOptions.Length > 0 && sortedOptions.All(o => - !o.EndsWith("[]") && - !string.IsNullOrEmpty(o) && - !SchemaHelpers.PrimitiveTypeNames.Contains(o) && - (char.IsLower(o[0]) || o.All(c => !char.IsLetter(c) || char.IsLower(c) || c == '_'))); + var allEnumLike = sortedOptions.Length > 0 && + sortedOptions.All( + o => + !o.EndsWith("[]") && !string.IsNullOrEmpty(o) && !SchemaHelpers.PrimitiveTypeNames.Contains(o) && + (char.IsLower(o[0]) || o.All(c => !char.IsLetter(c) || char.IsLower(c) || c == '_')) + ); if (allEnumLike) return new UnionDisplay { Kind = UnionDisplayKind.EnumLike, EnumLikeValues = sortedOptions }; @@ -331,9 +369,7 @@ private UnionDisplay BuildSimpleArrayUnionDisplay(TypeInfo typeInfo, string base var baseTypeOption = typeInfo.AnyOfOptions!.FirstOrDefault(o => o.Name == baseName); var baseTypeInfo = baseTypeOption?.Schema is not null ? _analyzer.GetTypeInfo(baseTypeOption.Schema) : null; var isBaseValueType = baseTypeInfo?.IsValueType ?? false; - var valueTypePrefix = isBaseValueType && !string.IsNullOrEmpty(baseTypeInfo?.ValueTypeBase) - ? baseTypeInfo.ValueTypeBase + " " - : ""; + var valueTypePrefix = isBaseValueType && !string.IsNullOrEmpty(baseTypeInfo?.ValueTypeBase) ? baseTypeInfo.ValueTypeBase + " " : ""; return new UnionDisplay { Kind = UnionDisplayKind.SimpleArrayUnion, @@ -344,9 +380,10 @@ private UnionDisplay BuildSimpleArrayUnionDisplay(TypeInfo typeInfo, string base } internal static bool IsTypeOptionBadge(string option) => - SchemaHelpers.PrimitiveTypeNames.Contains(option) || - SchemaHelpers.PrimitiveTypeNames.Contains(option.TrimEnd('[', ']')) || - char.IsUpper(option[0]) || option.EndsWith("[]"); + SchemaHelpers.PrimitiveTypeNames.Contains(option) + || SchemaHelpers.PrimitiveTypeNames.Contains(option.TrimEnd('[', ']')) + || char.IsUpper(option[0]) + || option.EndsWith("[]"); private ApiPropertyChildren BuildChildren(PropertyRow row, PropertyTreeScope scope, Expansion expansion) { @@ -434,8 +471,11 @@ private ApiPropertyChildren BuildDictionaryChildren(PropertyRow row, PropertyTre NestedCount = expansion.NestedCount, UseHidden = options.UseHiddenUntilFound && dictIsCollapsible && !dictDefaultExpanded, ValueType = Describe(row.TypeInfo.DictValueSchema), - Properties = BuildPropertyList(row.TypeInfo.DictValueSchema, childScope with { Prefix = keyAnchorId, Depth = childScope.Depth + 1 }) - ?? new ApiPropertyList([]) + Properties = + BuildPropertyList( + row.TypeInfo.DictValueSchema, + childScope with { Prefix = keyAnchorId, Depth = childScope.Depth + 1 } + ) ?? new ApiPropertyList([]) } }; } @@ -466,18 +506,21 @@ private bool DetectRecursion(IOpenApiSchema propSchema, TypeInfo typeInfo, IRead if (IsAncestorType(typeInfo.TypeName, ancestors)) return true; - if (typeInfo.IsArray && propSchema.Items is not null - && IsAncestorType(_analyzer.GetTypeInfo(propSchema.Items).TypeName, ancestors)) + if (typeInfo.IsArray && propSchema.Items is not null && IsAncestorType(_analyzer.GetTypeInfo(propSchema.Items).TypeName, ancestors)) return true; - if (typeInfo is { IsDictionary: true, DictValueSchema: not null } - && IsAncestorType(_analyzer.GetTypeInfo(typeInfo.DictValueSchema).TypeName, ancestors)) + if ( + typeInfo is { IsDictionary: true, DictValueSchema: not null } && + IsAncestorType(_analyzer.GetTypeInfo(typeInfo.DictValueSchema).TypeName, ancestors) + ) return true; - if (typeInfo is { IsUnion: true, AnyOfOptions: not null } - && typeInfo.AnyOfOptions + if ( + typeInfo is { IsUnion: true, AnyOfOptions: not null } && + typeInfo.AnyOfOptions .Select(option => option.Name.EndsWith("[]") ? option.Name[..^2] : option.Name) - .Any(baseName => IsAncestorType(baseName, ancestors))) + .Any(baseName => IsAncestorType(baseName, ancestors)) + ) return true; return DetectDirectUnionRecursion(propSchema, ancestors); @@ -497,8 +540,11 @@ private bool DetectDirectUnionRecursion(IOpenApiSchema propSchema, IReadOnlySet< if (IsAncestorType(baseName, ancestors)) return true; - if (unionTypeInfo.IsArray && unionSchema.Items is not null - && IsAncestorType(_analyzer.GetTypeInfo(unionSchema.Items).TypeName, ancestors)) + if ( + unionTypeInfo.IsArray + && unionSchema.Items is not null + && IsAncestorType(_analyzer.GetTypeInfo(unionSchema.Items).TypeName, ancestors) + ) return true; } @@ -518,9 +564,11 @@ private static bool IsAncestorType(string? typeName, IReadOnlySet ancest return null; // Children of union variants always show deprecated/version/external-docs regardless of page settings. - var childBuilder = new ApiPropertyTreeBuilder(document, + var childBuilder = new ApiPropertyTreeBuilder( + document, options with { ShowDeprecated = true, ShowVersionInfo = true, ShowExternalDocs = true }, - currentPageType); + currentPageType + ); var variants = new List(variantsToRender.Count); foreach (var variant in variantsToRender) @@ -550,9 +598,10 @@ private static bool IsAncestorType(string? typeName, IReadOnlySet ancest NestedCount = nestedCount, UseHidden = options.UseHiddenUntilFound && isCollapsible && !defaultExpanded, Properties = showProperties && variant.Schema is not null - ? childBuilder.BuildPropertyList(variant.Schema, - scope with { Prefix = optionId, Depth = scope.Depth + 1, Ancestors = newAncestors, RequiredProperties = null }) - ?? new ApiPropertyList([]) + ? childBuilder.BuildPropertyList( + variant.Schema, + scope with { Prefix = optionId, Depth = scope.Depth + 1, Ancestors = newAncestors, RequiredProperties = null } + ) ?? new ApiPropertyList([]) : null }); } @@ -567,20 +616,25 @@ private static bool IsAncestorType(string? typeName, IReadOnlySet ancest } private sealed record VariantCandidate( - string Name, string BaseName, bool IsArray, bool IsObject, - IOpenApiSchema? Schema, IDictionary? Props); + string Name, + string BaseName, + bool IsArray, + bool IsObject, + IOpenApiSchema? Schema, + IDictionary? Props + ); private List CollectVariantsToRender(List unionOptions) { // Sort: array variants first within each base-name group, preserving group order - var sortedOptions = unionOptions - .GroupBy(o => o.Name.EndsWith("[]") ? o.Name[..^2] : o.Name) + var sortedOptions = unionOptions.GroupBy(o => o.Name.EndsWith("[]") ? o.Name[..^2] : o.Name) .SelectMany(g => g.OrderByDescending(o => o.Name.EndsWith("[]"))) .ToList(); - var typeGroups = sortedOptions - .GroupBy(o => o.Name.EndsWith("[]") ? o.Name[..^2] : o.Name) - .ToDictionary(g => g.Key, g => g.ToList()); + var typeGroups = sortedOptions.GroupBy(o => o.Name.EndsWith("[]") ? o.Name[..^2] : o.Name).ToDictionary( + g => g.Key, + g => g.ToList() + ); var variantsToRender = new List(); foreach (var (baseName, variants) in typeGroups) diff --git a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiUnionVariants.cs b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiUnionVariants.cs index de28110c04..fd7c0628ec 100644 --- a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiUnionVariants.cs +++ b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiUnionVariants.cs @@ -26,7 +26,13 @@ public record ApiUnionVariant public record ApiUnionVariants { /// Renders nothing; used where the original template early-returned but its wrapper still rendered. - public static readonly ApiUnionVariants Empty = new() { Variants = [], ShouldCollapse = false, ContainerId = "", UseHiddenUntilFound = false }; + public static readonly ApiUnionVariants Empty = new() + { + Variants = [], + ShouldCollapse = false, + ContainerId = "", + UseHiddenUntilFound = false + }; public required IReadOnlyList Variants { get; init; } public required bool ShouldCollapse { get; init; } diff --git a/src/Elastic.ApiExplorer/Export/OpenApiDocumentExporter.cs b/src/Elastic.ApiExplorer/Export/OpenApiDocumentExporter.cs index 07b8e68b59..80666af674 100644 --- a/src/Elastic.ApiExplorer/Export/OpenApiDocumentExporter.cs +++ b/src/Elastic.ApiExplorer/Export/OpenApiDocumentExporter.cs @@ -24,9 +24,7 @@ namespace Elastic.ApiExplorer.Export; /// /// Exports OpenAPI specifications from CloudFront URLs and converts them to DocumentationDocument instances. /// -public partial class OpenApiDocumentExporter( - VersionsConfiguration versionsConfiguration, - IDocumentInferrerService? documentInferrer = null) +public partial class OpenApiDocumentExporter(VersionsConfiguration versionsConfiguration, IDocumentInferrerService? documentInferrer = null) { private static readonly HttpClient HttpClient = new(); @@ -45,7 +43,10 @@ public partial class OpenApiDocumentExporter( /// Optional limit of documents to return per source (Elasticsearch and Kibana) /// Cancellation token /// Enumerable of DocumentationDocument instances for all endpoints - public async IAsyncEnumerable ExportDocuments(int? limitPerSource = null, [EnumeratorCancellation] Cancel ctx = default) + public async IAsyncEnumerable ExportDocuments( + int? limitPerSource = null, + [EnumeratorCancellation] Cancel ctx = default + ) { // Process Elasticsearch API var elasticsearchCount = 0; @@ -71,10 +72,7 @@ public async IAsyncEnumerable ExportDocuments(int? limitP /// /// Fetches OpenAPI spec from a URL and converts it to DocumentationDocument instances. /// - private async IAsyncEnumerable ExportFromUrl( - string url, - string product, - [EnumeratorCancellation] Cancel ctx) + private async IAsyncEnumerable ExportFromUrl(string url, string product, [EnumeratorCancellation] Cancel ctx) { var openApiDocument = await FetchOpenApiDocument(url, ctx); if (openApiDocument == null) @@ -169,11 +167,9 @@ internal IEnumerable ConvertToDocuments(OpenApiDocument o var body = bodyBuilder.ToString(); // Extract tags as headings - var headings = operation.Value.Tags? - .Select(t => t.Name) - .Where(n => !string.IsNullOrEmpty(n)) - .OfType() - .ToArray() ?? []; + var headings = operation.Value.Tags?.Select(t => t.Name).Where( + n => !string.IsNullOrEmpty(n) + ).OfType().ToArray() ?? []; // Extract ApplicableTo from x-state var applies = ExtractApplicableTo(operation.Value); @@ -199,11 +195,9 @@ internal IEnumerable ConvertToDocuments(OpenApiDocument o ], Product = inference?.Product?.Id, RelatedProducts = inference?.RelatedProducts.Count > 0 - ? inference.RelatedProducts.Select(p => new IndexedProduct - { - Id = p.Id, - Repository = p.Repository ?? inference.Repository - }).ToArray() + ? inference.RelatedProducts + .Select(p => new IndexedProduct { Id = p.Id, Repository = p.Repository ?? inference.Repository }) + .ToArray() : null }; } @@ -277,20 +271,13 @@ private static string GenerateOperationId(HttpMethod method, string path) var version = ParseVersion(stateValue); // Create Applicability instance - var applicability = new Applicability - { - Lifecycle = lifecycle, - Version = version - }; + var applicability = new Applicability { Lifecycle = lifecycle, Version = version }; // Create AppliesCollection var appliesCollection = new AppliesCollection([applicability]); // Return ApplicableTo with Stack set - return new ApplicableTo - { - Stack = appliesCollection - }; + return new ApplicableTo { Stack = appliesCollection }; } /// diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiMarkdown.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiMarkdown.cs index 3256de5d3a..dbf29c5522 100644 --- a/src/Elastic.ApiExplorer/Infrastructure/ApiMarkdown.cs +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiMarkdown.cs @@ -38,9 +38,7 @@ private static string RewriteIntraApiLinks(string markdown, string apiBaseUrl) private static IFileInfo CreateVirtualSource(ApiRenderContext context) { - var relativePath = context.CurrentNavigation.Url - .TrimStart('/') - .TrimEnd('/'); + var relativePath = context.CurrentNavigation.Url.TrimStart('/').TrimEnd('/'); if (string.IsNullOrEmpty(relativePath)) relativePath = "api"; diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiRenderContext.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiRenderContext.cs index f8556f424f..f98d16ef22 100644 --- a/src/Elastic.ApiExplorer/Infrastructure/ApiRenderContext.cs +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiRenderContext.cs @@ -20,8 +20,7 @@ public record ApiRenderContext( BuildContext BuildContext, OpenApiDocument Model, StaticFileContentHashProvider StaticFileContentHashProvider -) - : RenderContext(BuildContext, Model) +) : RenderContext(BuildContext, Model) { public required string NavigationHtml { get; init; } public required INavigationItem CurrentNavigation { get; init; } diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiUrlBuilder.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiUrlBuilder.cs index 7401b2fb52..126ed2c2b3 100644 --- a/src/Elastic.ApiExplorer/Infrastructure/ApiUrlBuilder.cs +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiUrlBuilder.cs @@ -12,11 +12,9 @@ namespace Elastic.ApiExplorer.Infrastructure; /// public static partial class ApiUrlBuilder { - public static string ApiRoot(string? urlPathPrefix) => - $"{urlPathPrefix?.TrimEnd('/')}/api"; + public static string ApiRoot(string? urlPathPrefix) => $"{urlPathPrefix?.TrimEnd('/')}/api"; - public static string ProductRoot(string? urlPathPrefix, string apiUrlSuffix) => - $"{ApiRoot(urlPathPrefix)}/doc/{apiUrlSuffix}"; + public static string ProductRoot(string? urlPathPrefix, string apiUrlSuffix) => $"{ApiRoot(urlPathPrefix)}/doc/{apiUrlSuffix}"; /// /// URL path suffix for one API product version: {key} for main, @@ -39,8 +37,7 @@ public static string OperationMoniker(string? operationId, string route) } /// Deterministic URL segment for a schema type page under .../types/. - public static string SchemaMoniker(string schemaId) => - schemaId.Replace('.', '-').ToLowerInvariant(); + public static string SchemaMoniker(string schemaId) => schemaId.Replace('.', '-').ToLowerInvariant(); /// Deterministic URL leaf for .../group/{segment} from the canonical tag name. public static string TagMoniker(string? tagName) diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiVersionSwitcher.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiVersionSwitcher.cs index d9241633d8..8a22351d28 100644 --- a/src/Elastic.ApiExplorer/Infrastructure/ApiVersionSwitcher.cs +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiVersionSwitcher.cs @@ -10,20 +10,23 @@ public static IReadOnlyList Build( string? urlPathPrefix, string apiKey, IReadOnlyList monikers, - string currentMoniker) + string currentMoniker + ) { if (monikers.Count <= 1) return []; - return monikers - .OrderByDescending(m => m == "main" ? int.MaxValue : ParseMajor(m)) - .Select(m => new ApiVersionSwitcherItem( - Label: m == "main" ? "Latest" : $"{m}.x", - Url: $"{ApiUrlBuilder.ProductRoot(urlPathPrefix, ApiUrlBuilder.ProductSuffix(apiKey, m))}/", - Selected: m == currentMoniker)) + return monikers.OrderByDescending(m => m == "main" ? int.MaxValue : ParseMajor(m)) + .Select( + m => + new ApiVersionSwitcherItem( + Label: m == "main" ? "Latest" : $"{m}.x", + Url: $"{ApiUrlBuilder.ProductRoot(urlPathPrefix, ApiUrlBuilder.ProductSuffix(apiKey, m))}/", + Selected: m == currentMoniker + ) + ) .ToArray(); } - private static int ParseMajor(string moniker) => - int.TryParse(moniker, out var major) ? major : 0; + private static int ParseMajor(string moniker) => int.TryParse(moniker, out var major) ? major : 0; } diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs index 116d351fc2..bfdf8e6080 100644 --- a/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs @@ -28,16 +28,20 @@ public record ApiLayoutViewModel : GlobalLayoutViewModel public abstract class ApiViewModel(ApiRenderContext context) { public string NavigationHtml { get; } = context?.NavigationHtml ?? string.Empty; - public StaticFileContentHashProvider StaticFileContentHashProvider { get; } = context?.StaticFileContentHashProvider ?? throw new ArgumentNullException(nameof(context), "StaticFileContentHashProvider cannot be null"); - public INavigationItem CurrentNavigationItem { get; } = context?.CurrentNavigation ?? throw new ArgumentNullException(nameof(context), "CurrentNavigation cannot be null"); - public IMarkdownStringRenderer MarkdownRenderer { get; } = context?.MarkdownRenderer ?? throw new ArgumentNullException(nameof(context), "MarkdownRenderer cannot be null"); - public BuildContext BuildContext { get; } = context?.BuildContext ?? throw new ArgumentNullException(nameof(context), "BuildContext cannot be null"); - public OpenApiDocument Document { get; } = context?.Model ?? throw new ArgumentNullException(nameof(context), "OpenApiDocument cannot be null"); + public StaticFileContentHashProvider StaticFileContentHashProvider { get; } = context?.StaticFileContentHashProvider ?? + throw new ArgumentNullException(nameof(context), "StaticFileContentHashProvider cannot be null"); + public INavigationItem CurrentNavigationItem { get; } = context?.CurrentNavigation ?? + throw new ArgumentNullException(nameof(context), "CurrentNavigation cannot be null"); + public IMarkdownStringRenderer MarkdownRenderer { get; } = context?.MarkdownRenderer ?? + throw new ArgumentNullException(nameof(context), "MarkdownRenderer cannot be null"); + public BuildContext BuildContext { get; } = context?.BuildContext ?? + throw new ArgumentNullException(nameof(context), "BuildContext cannot be null"); + public OpenApiDocument Document { get; } = context?.Model ?? + throw new ArgumentNullException(nameof(context), "OpenApiDocument cannot be null"); /// Current API render context (OpenAPI model, nav, optional logging). protected ApiRenderContext RenderContext { get; } = context ?? throw new ArgumentNullException(nameof(context)); - public HtmlString RenderMarkdown(string? markdown) => ApiMarkdown.Render(RenderContext, markdown); protected virtual IReadOnlyList GetTocItems() => []; @@ -58,9 +62,7 @@ public ApiLayoutViewModel CreateGlobalLayoutModel() { var docTitle = Document.Info?.Title ?? "API Documentation"; var pageTitle = LayoutPageTitle; - var documentTitle = pageTitle is not null - ? $"{pageTitle} | {docTitle}" - : docTitle; + var documentTitle = pageTitle is not null ? $"{pageTitle} | {docTitle}" : docTitle; return new() { diff --git a/src/Elastic.ApiExplorer/Infrastructure/AvailabilityBadgeHelper.cs b/src/Elastic.ApiExplorer/Infrastructure/AvailabilityBadgeHelper.cs index 250c3f43ed..87e7395fb0 100644 --- a/src/Elastic.ApiExplorer/Infrastructure/AvailabilityBadgeHelper.cs +++ b/src/Elastic.ApiExplorer/Infrastructure/AvailabilityBadgeHelper.cs @@ -56,7 +56,8 @@ public static partial class AvailabilityBadgeHelper private static AvailabilityBadgeData? FromExtensions( IDictionary? extensions, - VersionsConfiguration? versionsConfig) + VersionsConfiguration? versionsConfig + ) { if (extensions is null || !extensions.TryGetValue("x-state", out var stateExtension)) return null; @@ -64,9 +65,11 @@ public static partial class AvailabilityBadgeHelper if (stateExtension is not JsonNodeExtension jsonNodeExtension) return null; - if (jsonNodeExtension.Node is not JsonValue jsonValue + if ( + jsonNodeExtension.Node is not JsonValue jsonValue || !jsonValue.TryGetValue(out var stateValue) - || string.IsNullOrEmpty(stateValue)) + || string.IsNullOrEmpty(stateValue) + ) return null; var lifecycleString = ProjectToLifecycleFormat(stateValue); @@ -109,9 +112,7 @@ _ when lower.Contains("generally available") => "ga", return lifecycle; } - private static AvailabilityBadgeData? BuildBadgeData( - ApplicableTo applicableTo, - VersionsConfiguration? versionsConfig) + private static AvailabilityBadgeData? BuildBadgeData(ApplicableTo applicableTo, VersionsConfiguration? versionsConfig) { if (applicableTo.Stack is null) return null; @@ -181,9 +182,7 @@ _ when lower.Contains("generally available") => "ga", private static string FormatVersion(VersionSpec versionSpec) { var min = versionSpec.Min; - var minVersion = versionSpec.ShowMinPatch - ? $"{min.Major}.{min.Minor}.{min.Patch}" - : $"{min.Major}.{min.Minor}"; + var minVersion = versionSpec.ShowMinPatch ? $"{min.Major}.{min.Minor}.{min.Patch}" : $"{min.Major}.{min.Minor}"; return versionSpec.Kind switch { diff --git a/src/Elastic.ApiExplorer/Landing/ApiCatalog.cs b/src/Elastic.ApiExplorer/Landing/ApiCatalog.cs index d163b4ee9d..d9cce0be37 100644 --- a/src/Elastic.ApiExplorer/Landing/ApiCatalog.cs +++ b/src/Elastic.ApiExplorer/Landing/ApiCatalog.cs @@ -51,5 +51,7 @@ public ApiCatalogNavigationItem(string url, IReadOnlyList entri public bool IsUsingNavigationDropdown => false; void IAssignableChildrenNavigation.SetNavigationItems(IReadOnlyCollection navigationItems) => - throw new NotSupportedException($"{nameof(IAssignableChildrenNavigation.SetNavigationItems)} is not supported on {nameof(ApiCatalogNavigationItem)}."); + throw new NotSupportedException( + $"{nameof(IAssignableChildrenNavigation.SetNavigationItems)} is not supported on {nameof(ApiCatalogNavigationItem)}." + ); } diff --git a/src/Elastic.ApiExplorer/Landing/ApiOverviewRow.cs b/src/Elastic.ApiExplorer/Landing/ApiOverviewRow.cs index eb0595fd4a..e71521a1a0 100644 --- a/src/Elastic.ApiExplorer/Landing/ApiOverviewRow.cs +++ b/src/Elastic.ApiExplorer/Landing/ApiOverviewRow.cs @@ -72,7 +72,12 @@ private static void AddProductRows(INavigationItem item, List ro AddEndpointRow(endpoint, rows, AddProductRows); break; case OperationNavigationItem operation: - rows.Add(new ApiOverviewRow { Kind = OverviewRowKind.Operation, Title = operation.NavigationTitle, Operations = [operation] }); + rows.Add(new ApiOverviewRow + { + Kind = OverviewRowKind.Operation, + Title = operation.NavigationTitle, + Operations = [operation] + }); break; case SchemaCategoryNavigationItem schemaCategory: rows.Add(new ApiOverviewRow { Kind = OverviewRowKind.SchemaCategoryHeading, Title = schemaCategory.NavigationTitle }); @@ -88,7 +93,12 @@ private static void AddProductRows(INavigationItem item, List ro }); break; case SimpleMarkdownNavigationItem markdownPage: - rows.Add(new ApiOverviewRow { Kind = OverviewRowKind.MarkdownPage, Title = markdownPage.NavigationTitle, Url = markdownPage.Url }); + rows.Add(new ApiOverviewRow + { + Kind = OverviewRowKind.MarkdownPage, + Title = markdownPage.NavigationTitle, + Url = markdownPage.Url + }); break; default: throw new InvalidOperationException($"Unexpected type: {navigationItem.GetType().FullName}"); @@ -109,7 +119,12 @@ private static void AddTagRows(INavigationItem item, List rows) AddEndpointRow(endpoint, rows, AddTagRows); break; case OperationNavigationItem operation: - rows.Add(new ApiOverviewRow { Kind = OverviewRowKind.Operation, Title = operation.NavigationTitle, Operations = [operation] }); + rows.Add(new ApiOverviewRow + { + Kind = OverviewRowKind.Operation, + Title = operation.NavigationTitle, + Operations = [operation] + }); break; default: throw new InvalidOperationException($"Unexpected type on tag landing: {navigationItem.GetType().FullName}"); @@ -117,13 +132,22 @@ private static void AddTagRows(INavigationItem item, List rows) } } - private static void AddEndpointRow(EndpointNavigationItem endpoint, List rows, Action> recurse) + private static void AddEndpointRow( + EndpointNavigationItem endpoint, + List rows, + Action> recurse + ) { var endpointOperations = endpoint is { NavigationItems.Count: > 0 } && endpoint.NavigationItems.All(n => n.Hidden) ? endpoint.NavigationItems : []; if (endpointOperations.Count > 0) - rows.Add(new ApiOverviewRow { Kind = OverviewRowKind.Endpoint, Title = endpoint.NavigationTitle, Operations = endpointOperations }); + rows.Add(new ApiOverviewRow + { + Kind = OverviewRowKind.Endpoint, + Title = endpoint.NavigationTitle, + Operations = endpointOperations + }); else recurse(endpoint, rows); } diff --git a/src/Elastic.ApiExplorer/Landing/ApiTag.cs b/src/Elastic.ApiExplorer/Landing/ApiTag.cs index 4f0d59ab2f..ae32d38d22 100644 --- a/src/Elastic.ApiExplorer/Landing/ApiTag.cs +++ b/src/Elastic.ApiExplorer/Landing/ApiTag.cs @@ -19,7 +19,8 @@ public record ApiTag( string Description, ApiTagExternalDoc? ExternalDocs, string TagUrlSegment, - IReadOnlyCollection Endpoints) : IApiGroupingModel + IReadOnlyCollection Endpoints +) : IApiGroupingModel { /// public async Task RenderAsync(FileSystemStream stream, ApiRenderContext context, Cancel ctx = default) diff --git a/src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs b/src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs index 299c8b04b6..476123f164 100644 --- a/src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs +++ b/src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs @@ -54,22 +54,18 @@ public LandingNavigationItem(string url) public bool IsUsingNavigationDropdown => false; void IAssignableChildrenNavigation.SetNavigationItems(IReadOnlyCollection navigationItems) => - throw new NotSupportedException($"{nameof(IAssignableChildrenNavigation.SetNavigationItems)} is not supported on ${nameof(ClassificationNavigationItem)}"); + throw new NotSupportedException( + $"{nameof(IAssignableChildrenNavigation.SetNavigationItems)} is not supported on ${nameof(ClassificationNavigationItem)}" + ); } -public interface IApiGroupingNavigationItem : INodeNavigationItem - where TGroupingModel : IApiGroupingModel - where TNavigationItem : INavigationItem; +public interface IApiGroupingNavigationItem : INodeNavigationItem where TGroupingModel : IApiGroupingModel where TNavigationItem : INavigationItem; public abstract class ApiGroupingNavigationItem( TGroupingModel groupingModel, IRootNavigationItem rootNavigation, INodeNavigationItem parent -) - : IApiGroupingNavigationItem - where TGroupingModel : IApiGroupingModel - where TNavigationItem : INavigationItem - +) : IApiGroupingNavigationItem where TGroupingModel : IApiGroupingModel where TNavigationItem : INavigationItem { /// public virtual string Url => NavigationItems.First().Url; @@ -95,14 +91,22 @@ INodeNavigationItem parent //TODO ensure Index is not newed everytime /// - public ILeafNavigationItem Index => new ApiIndexLeafNavigation(groupingModel, Url, NavigationTitle, rootNavigation, Parent); + public ILeafNavigationItem Index => + new ApiIndexLeafNavigation(groupingModel, Url, NavigationTitle, rootNavigation, Parent); /// public IReadOnlyCollection NavigationItems { get; set; } = []; } -public class ClassificationNavigationItem(ApiClassification classification, LandingNavigationItem rootNavigation, LandingNavigationItem parent) - : ApiGroupingNavigationItem(classification, rootNavigation, parent), IRootNavigationItem +public class ClassificationNavigationItem( + ApiClassification classification, + LandingNavigationItem rootNavigation, + LandingNavigationItem parent +) : ApiGroupingNavigationItem( + classification, + rootNavigation, + parent +), IRootNavigationItem { /// Section titles from x-tagGroups are not their own page; the sidebar link targets the main API overview for the product, not a tag (or the first child) page. public override string Url => rootNavigation.Index.Url; @@ -117,7 +121,9 @@ public class ClassificationNavigationItem(ApiClassification classification, Land public bool IsUsingNavigationDropdown => false; void IAssignableChildrenNavigation.SetNavigationItems(IReadOnlyCollection navigationItems) => - throw new NotSupportedException($"{nameof(IAssignableChildrenNavigation.SetNavigationItems)} is not supported on ${nameof(ClassificationNavigationItem)}"); + throw new NotSupportedException( + $"{nameof(IAssignableChildrenNavigation.SetNavigationItems)} is not supported on ${nameof(ClassificationNavigationItem)}" + ); } public class TagNavigationItem( @@ -126,8 +132,7 @@ public class TagNavigationItem( string apiUrlSuffix, IRootNavigationItem rootNavigation, INodeNavigationItem parent -) - : ApiGroupingNavigationItem(tag, rootNavigation, parent) +) : ApiGroupingNavigationItem(tag, rootNavigation, parent) { private readonly string _url = $"{ApiUrlBuilder.ProductRoot(urlPathPrefix, apiUrlSuffix)}/group/{tag.TagUrlSegment}"; @@ -143,8 +148,11 @@ INodeNavigationItem parent public interface IEndpointOrOperationNavigationItem : INavigationItem; -public class EndpointNavigationItem(ApiEndpoint endpoint, IRootNavigationItem rootNavigation, INodeNavigationItem parent) - : IApiGroupingNavigationItem, IEndpointOrOperationNavigationItem +public class EndpointNavigationItem( + ApiEndpoint endpoint, + IRootNavigationItem rootNavigation, + INodeNavigationItem parent +) : IApiGroupingNavigationItem, IEndpointOrOperationNavigationItem { /// public string Url => NavigationItems.First().Url; @@ -165,11 +173,16 @@ public class EndpointNavigationItem(ApiEndpoint endpoint, IRootNavigationItem - public string Id { get; } = ShortId.Create(nameof(EndpointNavigationItem), endpoint.Operations.First().ApiName, endpoint.Operations.First().Route); + public string Id { get; } = ShortId.Create( + nameof(EndpointNavigationItem), + endpoint.Operations.First().ApiName, + endpoint.Operations.First().Route + ); //TODO ensure Index is not newed everytime /// - public ILeafNavigationItem Index => new ApiIndexLeafNavigation(endpoint, Url, NavigationTitle, rootNavigation, Parent); + public ILeafNavigationItem Index => + new ApiIndexLeafNavigation(endpoint, Url, NavigationTitle, rootNavigation, Parent); /// public IReadOnlyCollection NavigationItems { get; set; } = []; diff --git a/src/Elastic.ApiExplorer/Landing/SimpleMarkdownNavigationItem.cs b/src/Elastic.ApiExplorer/Landing/SimpleMarkdownNavigationItem.cs index ba04ca9361..6e3adf0105 100644 --- a/src/Elastic.ApiExplorer/Landing/SimpleMarkdownNavigationItem.cs +++ b/src/Elastic.ApiExplorer/Landing/SimpleMarkdownNavigationItem.cs @@ -21,7 +21,8 @@ public class SimpleMarkdownNavigationItem( string url, string title, IFileInfo fileInfo, - IRootNavigationItem navigationRoot) : INavigationItem, IApiModel, ILeafNavigationItem + IRootNavigationItem navigationRoot +) : INavigationItem, IApiModel, ILeafNavigationItem { public string Url { get; } = url; public string NavigationTitle { get; } = title; @@ -41,9 +42,7 @@ public class SimpleMarkdownNavigationItem( public static string CreateSlugFromFile(IFileInfo markdownFile) { var fileName = Path.GetFileNameWithoutExtension(markdownFile.Name); - return fileName.ToLowerInvariant() - .Replace(' ', '-') - .Replace('_', '-'); + return fileName.ToLowerInvariant().Replace(' ', '-').Replace('_', '-'); } /// Throws if the slug collides with reserved API Explorer path segments. @@ -54,7 +53,8 @@ public static void ValidateSlugForCollisions(string slug, string productKey, str if (reservedSegments.Contains(slug, StringComparer.OrdinalIgnoreCase)) { throw new InvalidOperationException( - $"Markdown file slug '{slug}' (from '{filePath}') conflicts with reserved API Explorer segment in product '{productKey}'. Reserved segments: {string.Join(", ", reservedSegments)}"); + $"Markdown file slug '{slug}' (from '{filePath}') conflicts with reserved API Explorer segment in product '{productKey}'. Reserved segments: {string.Join(", ", reservedSegments)}" + ); } } diff --git a/src/Elastic.ApiExplorer/Landing/TagLandingViewModel.cs b/src/Elastic.ApiExplorer/Landing/TagLandingViewModel.cs index 9e2a768b7c..dbf23cff45 100644 --- a/src/Elastic.ApiExplorer/Landing/TagLandingViewModel.cs +++ b/src/Elastic.ApiExplorer/Landing/TagLandingViewModel.cs @@ -25,7 +25,8 @@ Tag.ExternalDocs is null : new TagExternalDocsDisplay( Tag.ExternalDocs.Url, ApiPropertyTreeBuilder.IsElasticDocsUrl(Tag.ExternalDocs.Url), - string.IsNullOrWhiteSpace(Tag.ExternalDocs.Description) ? "Documentation" : Tag.ExternalDocs.Description); + string.IsNullOrWhiteSpace(Tag.ExternalDocs.Description) ? "Documentation" : Tag.ExternalDocs.Description + ); /// protected override string? LayoutPageTitle => Tag.DisplayName; diff --git a/src/Elastic.ApiExplorer/Model/OpenApiExtensionReader.cs b/src/Elastic.ApiExplorer/Model/OpenApiExtensionReader.cs index b90a340b73..be747d10d8 100644 --- a/src/Elastic.ApiExplorer/Model/OpenApiExtensionReader.cs +++ b/src/Elastic.ApiExplorer/Model/OpenApiExtensionReader.cs @@ -24,9 +24,7 @@ public static class OpenApiExtensionReader { /// Reads a string-valued extension; null when absent or not a string. public static string? TryGetString(IDictionary? extensions, string key) => - extensions?.TryGetValue(key, out var value) == true && value is JsonNodeExtension json - ? json.Node.GetValue() - : null; + extensions?.TryGetValue(key, out var value) == true && value is JsonNodeExtension json ? json.Node.GetValue() : null; /// The x-namespace and x-api-name pair used to group operations into endpoints. public static (string? Namespace, string? ApiName) GetNamespaceAndApiName(OpenApiOperation operation) => @@ -35,9 +33,10 @@ public static (string? Namespace, string? ApiName) GetNamespaceAndApiName(OpenAp /// Whether the operation is marked x-beta: true. public static bool IsBeta(OpenApiOperation operation) => operation.Extensions?.TryGetValue("x-beta", out var betaValue) == true - && betaValue is JsonNodeExtension betaExtension - && betaExtension.Node is JsonValue betaJsonValue - && betaJsonValue.TryGetValue(out var betaFlag) && betaFlag; + && betaValue is JsonNodeExtension betaExtension + && betaExtension.Node is JsonValue betaJsonValue + && betaJsonValue.TryGetValue(out var betaFlag) + && betaFlag; /// Parses the document-level x-tagGroups extension; null when absent or empty. public static XTagGroups? ParseXTagGroups(OpenApiDocument openApiDocument) @@ -127,9 +126,11 @@ public static Dictionary ParseTagMetadata(OpenApiDoc /// public static IReadOnlyList ParseCodeSamples(OpenApiOperation operation) { - if (operation.Extensions?.TryGetValue("x-codeSamples", out var ext) != true + if ( + operation.Extensions?.TryGetValue("x-codeSamples", out var ext) != true || ext is not JsonNodeExtension jsonExt - || jsonExt.Node is not JsonArray samplesArray) + || jsonExt.Node is not JsonArray samplesArray + ) return []; var samples = new List(); diff --git a/src/Elastic.ApiExplorer/Model/OpenApiReader.cs b/src/Elastic.ApiExplorer/Model/OpenApiReader.cs index d739c8162c..18b39d33b2 100644 --- a/src/Elastic.ApiExplorer/Model/OpenApiReader.cs +++ b/src/Elastic.ApiExplorer/Model/OpenApiReader.cs @@ -50,11 +50,7 @@ private static bool SupportsSpecFileName(string specFileName) => await using var jsonStream = await ParseSpecToJsonStreamAsync(stream).ConfigureAwait(false); - var settings = new OpenApiReaderSettings - { - LeaveStreamOpen = false, - RuleSet = ValidationRuleSet.GetEmptyRuleSet() - }; + var settings = new OpenApiReaderSettings { LeaveStreamOpen = false, RuleSet = ValidationRuleSet.GetEmptyRuleSet() }; var openApiDocument = await OpenApiDocument.LoadAsync(jsonStream, JsonFormat, settings: settings); return openApiDocument.Document; } @@ -65,8 +61,7 @@ private static async Task ParseSpecToJsonStreamAsync(Stream specSt var yaml = new YamlStream(); yaml.Load(reader); - var root = yaml.Documents[0].RootNode - ?? throw new InvalidOperationException("OpenAPI spec document is empty."); + var root = yaml.Documents[0].RootNode ?? throw new InvalidOperationException("OpenAPI spec document is empty."); var jsonStream = new MemoryStream(); await using (var jsonWriter = new Utf8JsonWriter(jsonStream)) diff --git a/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs b/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs index 6cc696d1cc..4e715ce3e1 100644 --- a/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs +++ b/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs @@ -21,8 +21,7 @@ public class SchemaAnalyzer(OpenApiDocument document, string? currentPageType = /// /// Checks if a type should link to its container page, considering the current page. /// - private bool IsLinkedType(string typeName) => - SchemaHelpers.ShouldLinkToContainerPage(typeName, currentPageType); + private bool IsLinkedType(string typeName) => SchemaHelpers.ShouldLinkToContainerPage(typeName, currentPageType); /// /// Resolves a schema reference to its target schema. @@ -62,8 +61,7 @@ private bool IsLinkedType(string typeName) => // Try resolving via Reference.Id var refId = schemaRef.Reference.Id; - if (!string.IsNullOrEmpty(refId) && - document.Components?.Schemas?.TryGetValue(refId, out var resolvedSchema) == true) + if (!string.IsNullOrEmpty(refId) && document.Components?.Schemas?.TryGetValue(refId, out var resolvedSchema) == true) { return GetSchemaProperties(resolvedSchema); } @@ -110,8 +108,7 @@ public List GetNestedUnionOptions(IOpenApiSchema? schema) if (unionSchemas == null && schema is OpenApiSchemaReference schemaRef) { var refId = schemaRef.Reference.Id; - if (!string.IsNullOrEmpty(refId) && - document.Components?.Schemas?.TryGetValue(refId, out var resolved) == true) + if (!string.IsNullOrEmpty(refId) && document.Components?.Schemas?.TryGetValue(refId, out var resolved) == true) { if (resolved.OneOf is { Count: > 0 }) unionSchemas = resolved.OneOf; @@ -181,8 +178,7 @@ public bool UnionOptionHasProperties(UnionOption option) if (string.IsNullOrEmpty(refId) && option.Schema is OpenApiSchemaReference schemaRef) refId = schemaRef.Reference.Id; - if (!string.IsNullOrEmpty(refId) && - document.Components?.Schemas?.TryGetValue(refId, out var resolvedSchema) == true) + if (!string.IsNullOrEmpty(refId) && document.Components?.Schemas?.TryGetValue(refId, out var resolvedSchema) == true) { props = GetSchemaProperties(resolvedSchema); if (props?.Count > 0) @@ -204,8 +200,7 @@ public bool UnionOptionHasProperties(UnionOption option) if (document.Components?.Schemas != null) { var baseName = option.Name.EndsWith("[]") ? option.Name[..^2] : option.Name; - var matchingSchema = document.Components.Schemas - .FirstOrDefault(kvp => kvp.Key.EndsWith("." + baseName) || kvp.Key == baseName); + var matchingSchema = document.Components.Schemas.FirstOrDefault(kvp => kvp.Key.EndsWith("." + baseName) || kvp.Key == baseName); if (matchingSchema.Value != null) { props = GetSchemaProperties(matchingSchema.Value); @@ -250,8 +245,7 @@ public List FlattenUnionOptions(List options) else if (schemaToCheck is OpenApiSchemaReference schemaRef) { var refId = schemaRef.Reference?.Id; - if (!string.IsNullOrEmpty(refId) && - document.Components?.Schemas?.TryGetValue(refId, out var resolved) == true) + if (!string.IsNullOrEmpty(refId) && document.Components?.Schemas?.TryGetValue(refId, out var resolved) == true) { resolvedSchema = resolved; props = GetSchemaProperties(resolved); @@ -262,7 +256,8 @@ public List FlattenUnionOptions(List options) if (!hasDirectProps && document.Components?.Schemas != null) { - var matchingSchema = document.Components.Schemas + var matchingSchema = document.Components + .Schemas .FirstOrDefault(kvp => kvp.Key.EndsWith("." + baseName) || kvp.Key == baseName); if (matchingSchema.Value != null) { @@ -348,8 +343,7 @@ public TypeInfo GetTypeInfo(IOpenApiSchema? schema) var itemSchema = schemaRef.Items; // If Items is null, try resolving the schema explicitly - if (itemSchema is null && - document.Components?.Schemas?.TryGetValue(refId, out var resolvedArraySchema) == true) + if (itemSchema is null && document.Components?.Schemas?.TryGetValue(refId, out var resolvedArraySchema) == true) itemSchema = resolvedArraySchema.Items; if (itemSchema is not null) @@ -359,9 +353,6 @@ public TypeInfo GetTypeInfo(IOpenApiSchema? schema) if (itemInfo is { IsObject: false, HasLink: false } && string.IsNullOrEmpty(itemInfo.SchemaRef)) arrayItemType = itemInfo.TypeName; } - - - } // Get union options from oneOf/anyOf @@ -379,7 +370,9 @@ public TypeInfo GetTypeInfo(IOpenApiSchema? schema) var unionTypeName = SchemaHelpers.FormatSchemaName(unionRef.Reference?.Id ?? "unknown"); options.Add(unionTypeName); // Also add to anyOfOptions for potential expansion - anyOfList.Add(new UnionOption(unionTypeName, unionRef.Reference?.Id, !SchemaHelpers.IsValueType(unionTypeName), s)); + anyOfList.Add( + new UnionOption(unionTypeName, unionRef.Reference?.Id, !SchemaHelpers.IsValueType(unionTypeName), s) + ); } else if (s.Enum is { Count: > 0 } inlineEnum) { @@ -410,7 +403,23 @@ public TypeInfo GetTypeInfo(IOpenApiSchema? schema) anyOfOptions = anyOfList.Count > 0 ? anyOfList : null; } - return new TypeInfo(typeName, refId, isArray, !isValueType && !isEnum, isValueType, valueTypeBase, hasLink, anyOfOptions, false, null, isEnum, isUnion, enumValues, unionOptions, arrayItemType); + return new TypeInfo( + typeName, + refId, + isArray, + !isValueType && !isEnum, + isValueType, + valueTypeBase, + hasLink, + anyOfOptions, + false, + null, + isEnum, + isUnion, + enumValues, + unionOptions, + arrayItemType + ); } } @@ -471,7 +480,9 @@ public TypeInfo GetTypeInfo(IOpenApiSchema? schema) var primitiveAliasType = !isValueType ? SchemaHelpers.GetPrimitiveAliasType(refSchemas[0]) : null; if (!string.IsNullOrEmpty(primitiveAliasType)) isValueType = true; - var valueTypeBase = isValueType ? (primitiveAliasType ?? SchemaHelpers.GetValueTypeBase(refSchemas[0]) ?? "string") : null; + var valueTypeBase = isValueType + ? (primitiveAliasType ?? SchemaHelpers.GetValueTypeBase(refSchemas[0]) ?? "string") + : null; var hasLink = IsLinkedType(typeName); return new TypeInfo(typeName, refId, false, !isValueType, isValueType, valueTypeBase, hasLink, null); } @@ -487,7 +498,17 @@ public TypeInfo GetTypeInfo(IOpenApiSchema? schema) // If the item is not an object and not a linked type, it's a primitive array var isPrimitiveArray = itemInfo is not { IsObject: false, HasLink: false } || !string.IsNullOrEmpty(itemInfo.SchemaRef); var arrayItemType = isPrimitiveArray ? itemInfo.TypeName : null; - return new TypeInfo(itemInfo.TypeName, itemInfo.SchemaRef, true, itemInfo.IsObject, itemInfo.IsValueType, itemInfo.ValueTypeBase, itemInfo.HasLink, null, ArrayItemType: arrayItemType); + return new TypeInfo( + itemInfo.TypeName, + itemInfo.SchemaRef, + true, + itemInfo.IsObject, + itemInfo.IsValueType, + itemInfo.ValueTypeBase, + itemInfo.HasLink, + null, + ArrayItemType: arrayItemType + ); } return new TypeInfo("unknown", null, true, false, false, null, false, null, ArrayItemType: "unknown"); } @@ -504,7 +525,18 @@ public TypeInfo GetTypeInfo(IOpenApiSchema? schema) { var valueInfo = GetTypeInfo(addProps); // Pass valueInfo.HasLink so we know if the dictionary value type has a dedicated page - return new TypeInfo($"string to {valueInfo.TypeName}", valueInfo.SchemaRef, false, true, false, null, valueInfo.HasLink, null, true, addProps); + return new TypeInfo( + $"string to {valueInfo.TypeName}", + valueInfo.SchemaRef, + false, + true, + false, + null, + valueInfo.HasLink, + null, + true, + addProps + ); } // Check if it has properties (inline object) diff --git a/src/Elastic.ApiExplorer/Model/SchemaHelpers.cs b/src/Elastic.ApiExplorer/Model/SchemaHelpers.cs index 0726b55a1c..f7f893702d 100644 --- a/src/Elastic.ApiExplorer/Model/SchemaHelpers.cs +++ b/src/Elastic.ApiExplorer/Model/SchemaHelpers.cs @@ -25,13 +25,47 @@ public static class SchemaHelpers public static readonly HashSet KnownValueTypes = [ with(StringComparer.OrdinalIgnoreCase), - "Field", "Fields", "Id", "Ids", "IndexName", "Indices", "Name", "Names", - "Routing", "VersionNumber", "SequenceNumber", "PropertyName", "RelationName", - "TaskId", "ScrollId", "SuggestionName", "Duration", "DateMath", "Fuzziness", - "GeoHashPrecision", "Distance", "TimeOfDay", "MinimumShouldMatch", "Script", - "ByteSize", "Percentage", "Stringifiedboolean", "ExpandWildcards", "float", "Stringifiedinteger", + "Field", + "Fields", + "Id", + "Ids", + "IndexName", + "Indices", + "Name", + "Names", + "Routing", + "VersionNumber", + "SequenceNumber", + "PropertyName", + "RelationName", + "TaskId", + "ScrollId", + "SuggestionName", + "Duration", + "DateMath", + "Fuzziness", + "GeoHashPrecision", + "Distance", + "TimeOfDay", + "MinimumShouldMatch", + "Script", + "ByteSize", + "Percentage", + "Stringifiedboolean", + "ExpandWildcards", + "float", + "Stringifiedinteger", // Numeric value types - "uint", "ulong", "long", "int", "short", "ushort", "byte", "sbyte", "double", "decimal" + "uint", + "ulong", + "long", + "int", + "short", + "ushort", + "byte", + "sbyte", + "double", + "decimal" ]; /// @@ -41,7 +75,9 @@ public static class SchemaHelpers public static readonly HashSet LinkedTypes = [ with(StringComparer.OrdinalIgnoreCase), - "QueryContainer", "AggregationContainer", "Aggregate" + "QueryContainer", + "AggregationContainer", + "Aggregate" ]; /// @@ -52,7 +88,13 @@ public static class SchemaHelpers public static readonly HashSet PrimitiveTypeNames = [ with(StringComparer.OrdinalIgnoreCase), - "boolean", "number", "string", "integer", "object", "null", "array" + "boolean", + "number", + "string", + "integer", + "object", + "null", + "array" ]; /// @@ -82,8 +124,7 @@ public static bool ShouldLinkToContainerPage(string typeName, string? currentPag return false; // Prevent self-linking on schema pages - if (!string.IsNullOrEmpty(currentPageType) && - typeName.Equals(currentPageType, StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrEmpty(currentPageType) && typeName.Equals(currentPageType, StringComparison.OrdinalIgnoreCase)) return false; return true; diff --git a/src/Elastic.ApiExplorer/Model/TypeInfo.cs b/src/Elastic.ApiExplorer/Model/TypeInfo.cs index 3c22874ee6..0cfe7489e6 100644 --- a/src/Elastic.ApiExplorer/Model/TypeInfo.cs +++ b/src/Elastic.ApiExplorer/Model/TypeInfo.cs @@ -10,12 +10,7 @@ namespace Elastic.ApiExplorer.Model; /// /// Represents a union option with full schema information. /// -public record UnionOption( - string Name, - string? Ref, - bool IsObject, - IOpenApiSchema? Schema -); +public record UnionOption(string Name, string? Ref, bool IsObject, IOpenApiSchema? Schema); /// /// Unified type information record used by both OperationView and SchemaView. diff --git a/src/Elastic.ApiExplorer/Model/VersionIndexClient.cs b/src/Elastic.ApiExplorer/Model/VersionIndexClient.cs index ea59a8edfe..64fd4dbf9f 100644 --- a/src/Elastic.ApiExplorer/Model/VersionIndexClient.cs +++ b/src/Elastic.ApiExplorer/Model/VersionIndexClient.cs @@ -51,12 +51,11 @@ public sealed class VersionIndexClient : IDisposable /// The public CloudFront distribution in front of the elastic-docs-openapi-specs bucket. public static readonly Uri DefaultBaseUri = new("https://d29hkgsdo66d1n.cloudfront.net/"); - private static readonly HttpClient SharedHttpClient = new( - new SocketsHttpHandler - { - AutomaticDecompression = DecompressionMethods.All, - PooledConnectionLifetime = TimeSpan.FromMinutes(5) - }) + private static readonly HttpClient SharedHttpClient = new(new SocketsHttpHandler + { + AutomaticDecompression = DecompressionMethods.All, + PooledConnectionLifetime = TimeSpan.FromMinutes(5) + }) { Timeout = FetchTimeout }; private readonly HttpClient _httpClient; @@ -75,7 +74,8 @@ public VersionIndexClient( Uri? baseUri = null, HttpMessageHandler? handler = null, int maxAttempts = DefaultMaxAttempts, - Func? sleep = null) + Func? sleep = null + ) { _baseUri = baseUri ?? DefaultBaseUri; _indexUri = new Uri(_baseUri, "index.json"); @@ -96,7 +96,8 @@ public async Task> ResolveVersionsAsync( string apiKey, ResolvedApiConfiguration apiConfig, IDiagnosticsCollector collector, - Cancel ctx = default) + Cancel ctx = default + ) { var repository = apiConfig.Repository ?? git.GitHubRepository; if (repository is null) @@ -110,12 +111,21 @@ public async Task> ResolveVersionsAsync( return MissingEntryFallback(apiKey, apiConfig, $"Version index at {_indexUri} declares no repositories", collector); if (!index.TryGetValue(repository, out var specsForRepo)) - return MissingEntryFallback(apiKey, apiConfig, $"Version index at {_indexUri} has no entry for repository '{repository}'", collector); + return MissingEntryFallback( + apiKey, + apiConfig, + $"Version index at {_indexUri} has no entry for repository '{repository}'", + collector + ); if (!specsForRepo.TryGetValue(apiConfig.SpecFileName, out var versions) || versions.Count == 0) { - return MissingEntryFallback(apiKey, apiConfig, - $"Version index at {_indexUri} has no entry for spec '{apiConfig.SpecFileName}' under repository '{repository}'", collector); + return MissingEntryFallback( + apiKey, + apiConfig, + $"Version index at {_indexUri} has no entry for spec '{apiConfig.SpecFileName}' under repository '{repository}'", + collector + ); } var resolved = new List(versions.Count); @@ -123,13 +133,7 @@ public async Task> ResolveVersionsAsync( { if (moniker == "main" && apiConfig.LocalSpecFile is { } localFile) { - resolved.Add(new ResolvedApiVersion - { - Moniker = moniker, - Version = entry.Version, - IsLocal = true, - LocalFile = localFile - }); + resolved.Add(new ResolvedApiVersion { Moniker = moniker, Version = entry.Version, IsLocal = true, LocalFile = localFile }); continue; } @@ -145,10 +149,17 @@ public async Task> ResolveVersionsAsync( return resolved; } - public async Task FetchSpecStreamAsync(string apiKey, ResolvedApiVersion version, IDiagnosticsCollector collector, Cancel ctx = default) + public async Task FetchSpecStreamAsync( + string apiKey, + ResolvedApiVersion version, + IDiagnosticsCollector collector, + Cancel ctx = default + ) { if (version.ObjectKey is not { } objectKey) - throw new InvalidOperationException($"Version '{version.Moniker}' of API '{apiKey}' is local; read {nameof(ResolvedApiVersion.LocalFile)} instead."); + throw new InvalidOperationException( + $"Version '{version.Moniker}' of API '{apiKey}' is local; read {nameof(ResolvedApiVersion.LocalFile)} instead." + ); var uri = new Uri(_baseUri, objectKey); string? lastError = null; @@ -171,27 +182,37 @@ public async Task> ResolveVersionsAsync( } collector.EmitGlobalWarning( - $"Could not fetch spec '{objectKey}' for version '{version.Moniker}' of API '{apiKey}' from {uri} after {attempts} attempt(s): {lastError}. Skipping this version."); + $"Could not fetch spec '{objectKey}' for version '{version.Moniker}' of API '{apiKey}' from {uri} after {attempts} attempt(s): {lastError}. Skipping this version." + ); return null; } private static ResolvedApiVersion LocalMain(IFileInfo localFile) => new() { Moniker = "main", Version = "main", IsLocal = true, LocalFile = localFile }; - private static IReadOnlyList NoRepositoryFallback(string apiKey, ResolvedApiConfiguration apiConfig, IDiagnosticsCollector collector) + private static IReadOnlyList NoRepositoryFallback( + string apiKey, + ResolvedApiConfiguration apiConfig, + IDiagnosticsCollector collector + ) { if (apiConfig.LocalSpecFile is { } localFile) return [LocalMain(localFile)]; collector.EmitGlobalError( $"API '{apiKey}' has no local spec file, and its repository could not be determined: there is no " + - "'repository:' override on the api entry and the current checkout has no resolvable GitHub remote. " + - "Add a local spec, set 'repository:' on the api entry, or build from a checkout with a GitHub remote."); + "'repository:' override on the api entry and the current checkout has no resolvable GitHub remote. " + + "Add a local spec, set 'repository:' on the api entry, or build from a checkout with a GitHub remote." + ); return []; } private static IReadOnlyList MissingEntryFallback( - string apiKey, ResolvedApiConfiguration apiConfig, string reason, IDiagnosticsCollector collector) + string apiKey, + ResolvedApiConfiguration apiConfig, + string reason, + IDiagnosticsCollector collector + ) { if (apiConfig.LocalSpecFile is { } localFile) { @@ -248,7 +269,8 @@ private static IReadOnlyList MissingEntryFallback( return await JsonSerializer.DeserializeAsync( stream, VersionIndexJsonContext.Default.DictionaryStringDictionaryStringDictionaryStringVersionIndexEntry, - ctx).ConfigureAwait(false); + ctx + ).ConfigureAwait(false); } catch (HttpRequestException ex) { @@ -271,8 +293,8 @@ private Task FetchStreamAsync(Uri uri, int attempt, Cancel ctx) } private static bool IsTransient(HttpRequestException exception) => - exception.StatusCode is null or HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests - || (exception.StatusCode is { } statusCode && (int)statusCode >= 500); + exception.StatusCode is null or HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests || + (exception.StatusCode is { } statusCode && (int)statusCode >= 500); private static TimeSpan RetryDelay(int attempt) { diff --git a/src/Elastic.ApiExplorer/Navigation/ApiIndexLeafNavigation.cs b/src/Elastic.ApiExplorer/Navigation/ApiIndexLeafNavigation.cs index 7059e8bfcf..cda6ddf6f6 100644 --- a/src/Elastic.ApiExplorer/Navigation/ApiIndexLeafNavigation.cs +++ b/src/Elastic.ApiExplorer/Navigation/ApiIndexLeafNavigation.cs @@ -10,11 +10,12 @@ namespace Elastic.ApiExplorer.Navigation; public class ApiIndexLeafNavigation( - TModel model, string url, string navigationTitle, + TModel model, + string url, + string navigationTitle, IRootNavigationItem rootNavigation, INodeNavigationItem? parent = null -) : ILeafNavigationItem - where TModel : IApiModel +) : ILeafNavigationItem where TModel : IApiModel { /// public string Url { get; } = url; diff --git a/src/Elastic.ApiExplorer/Navigation/ApiNavigationBuilder.cs b/src/Elastic.ApiExplorer/Navigation/ApiNavigationBuilder.cs index 59fc83e5c8..1f6abc6315 100644 --- a/src/Elastic.ApiExplorer/Navigation/ApiNavigationBuilder.cs +++ b/src/Elastic.ApiExplorer/Navigation/ApiNavigationBuilder.cs @@ -27,7 +27,11 @@ public class ApiNavigationBuilder(ILogger logger, BuildContext context) private readonly ILogger _logger = logger; - public LandingNavigationItem CreateNavigation(string apiUrlSuffix, OpenApiDocument openApiDocument, ResolvedApiConfiguration? apiConfig = null) + public LandingNavigationItem CreateNavigation( + string apiUrlSuffix, + OpenApiDocument openApiDocument, + ResolvedApiConfiguration? apiConfig = null + ) { var url = ApiUrlBuilder.ProductRoot(context.UrlPathPrefix, apiUrlSuffix); var rootNavigation = new LandingNavigationItem(url); @@ -47,23 +51,12 @@ public LandingNavigationItem CreateNavigation(string apiUrlSuffix, OpenApiDocume // Fall back to a deterministic route:method key; Guid.NewGuid() made grouping keys // (and thus navigation titles) change on every build. - var apiString = ns is null - ? api ?? op.Value.Summary ?? $"{pair.Path.Key}:{op.Key}" : $"{ns}.{api}"; - return new - { - Classification = tagClassification, - Api = apiString, - Tag = tag, - pair.Path, - pair.Operation - }; + var apiString = ns is null ? api ?? op.Value.Summary ?? $"{pair.Path.Key}:{op.Key}" : $"{ns}.{api}"; + return new { Classification = tagClassification, Api = apiString, Tag = tag, pair.Path, pair.Operation }; }) .ToArray(); - var distinctTagNames = ops - .Select(o => o.Tag ?? "unknown") - .Distinct() - .ToList(); + var distinctTagNames = ops.Select(o => o.Tag ?? "unknown").Distinct().ToList(); var tagNameToUrlSegment = BuildTagMonikerMap(distinctTagNames); // intermediate grouping of models to create the navigation tree @@ -100,16 +93,9 @@ public LandingNavigationItem CreateNavigation(string apiUrlSuffix, OpenApiDocume if (!tagMetadataByName.TryGetValue(tagName, out var tagMeta)) tagMeta = new OpenApiTagMetadata(tagName, string.Empty, null); if (!tagNameToUrlSegment.TryGetValue(tagName, out var urlSegment)) - throw new InvalidOperationException( - $"Internal error: no URL segment for OpenAPI tag '{tagName}'."); - - var tag = new ApiTag( - tagName, - tagMeta.DisplayName, - tagMeta.Description, - tagMeta.ExternalDocs, - urlSegment, - apis); + throw new InvalidOperationException($"Internal error: no URL segment for OpenAPI tag '{tagName}'."); + + var tag = new ApiTag(tagName, tagMeta.DisplayName, tagMeta.Description, tagMeta.ExternalDocs, urlSegment, apis); tags.Add(tag); } @@ -128,7 +114,13 @@ public LandingNavigationItem CreateNavigation(string apiUrlSuffix, OpenApiDocume var classificationNavigationItem = new ClassificationNavigationItem(classification, rootNavigation, rootNavigation); var tagNavigationItems = new List>(); - CreateTagNavigationItems(apiUrlSuffix, classification, classificationNavigationItem, classificationNavigationItem, tagNavigationItems); + CreateTagNavigationItems( + apiUrlSuffix, + classification, + classificationNavigationItem, + classificationNavigationItem, + tagNavigationItems + ); topLevelNavigationItems.Add(classificationNavigationItem); // if there is only a single tag item will be added directly to the classificationNavigationItem, otherwise they will be added to the tagNavigationItems if (classificationNavigationItem.NavigationItems.Count == 0) @@ -172,7 +164,8 @@ private SimpleMarkdownNavigationItem CreateMarkdownNavigationItem( IFileInfo markdownFile, LandingNavigationItem rootNavigation, INodeNavigationItem parent, - HashSet markdownSlugs) + HashSet markdownSlugs + ) { var slug = SimpleMarkdownNavigationItem.CreateSlugFromFile(markdownFile); @@ -180,8 +173,8 @@ private SimpleMarkdownNavigationItem CreateMarkdownNavigationItem( if (!markdownSlugs.Add(slug)) { throw new InvalidOperationException( - $"Duplicate markdown slug '{slug}' found in API product '{apiUrlSuffix}'. " + - $"File: {markdownFile.FullName}"); + $"Duplicate markdown slug '{slug}' found in API product '{apiUrlSuffix}'. " + $"File: {markdownFile.FullName}" + ); } SimpleMarkdownNavigationItem.ValidateSlugForCollisions(slug, apiUrlSuffix, markdownFile.FullName); @@ -190,10 +183,7 @@ private SimpleMarkdownNavigationItem CreateMarkdownNavigationItem( var title = MarkdownNavigationTitleReader.GetNavigationTitle(context.ReadFileSystem, markdownFile); // Create simple navigation item - will be handled by regular documentation system - var navItem = new SimpleMarkdownNavigationItem(url, title, markdownFile, rootNavigation) - { - Parent = parent - }; + var navItem = new SimpleMarkdownNavigationItem(url, title, markdownFile, rootNavigation) { Parent = parent }; return navItem; } @@ -232,10 +222,14 @@ List endpointNavigationItems var operationNavigationItems = new List(); foreach (var operation in endpoint.Operations) { - var operationNavigationItem = new OperationNavigationItem(context.UrlPathPrefix, apiUrlSuffix, operation, rootNavigation, endpointNavigationItem) - { - Hidden = true - }; + var operationNavigationItem = new OperationNavigationItem( + context.UrlPathPrefix, + apiUrlSuffix, + operation, + rootNavigation, + endpointNavigationItem + ) + { Hidden = true }; operationNavigationItems.Add(operationNavigationItem); } endpointNavigationItem.NavigationItems = operationNavigationItems; @@ -244,9 +238,14 @@ List endpointNavigationItems else { var operation = endpoint.Operations.First(); - var operationNavigationItem = new OperationNavigationItem(context.UrlPathPrefix, apiUrlSuffix, operation, rootNavigation, parentNavigationItem); + var operationNavigationItem = new OperationNavigationItem( + context.UrlPathPrefix, + apiUrlSuffix, + operation, + rootNavigation, + parentNavigationItem + ); endpointNavigationItems.Add(operationNavigationItem); - } } } @@ -267,8 +266,7 @@ List> topLevelNav var categoryNavigationItems = new List>(); // Query DSL - only show QueryContainer (individual queries are shown as properties within it) - var queryContainerSchema = schemas - .FirstOrDefault(s => s.Key == "_types.query_dsl.QueryContainer"); + var queryContainerSchema = schemas.FirstOrDefault(s => s.Key == "_types.query_dsl.QueryContainer"); if (queryContainerSchema.Value is not null) { @@ -284,11 +282,9 @@ List> topLevelNav } // Aggregations - only show AggregationContainer and Aggregate - var aggContainerSchema = schemas - .FirstOrDefault(s => s.Key == "_types.aggregations.AggregationContainer"); + var aggContainerSchema = schemas.FirstOrDefault(s => s.Key == "_types.aggregations.AggregationContainer"); - var aggregateSchema = schemas - .FirstOrDefault(s => s.Key == "_types.aggregations.Aggregate"); + var aggregateSchema = schemas.FirstOrDefault(s => s.Key == "_types.aggregations.Aggregate"); if (aggContainerSchema.Value is not null || aggregateSchema.Value is not null) { @@ -299,13 +295,17 @@ List> topLevelNav if (aggContainerSchema.Value is not null) { var apiSchema = new ApiSchema(aggContainerSchema.Key, "AggregationContainer", "aggregations", aggContainerSchema.Value); - aggNavigationItems.Add(new SchemaNavigationItem(context.UrlPathPrefix, apiUrlSuffix, apiSchema, rootNavigation, aggCategoryNav)); + aggNavigationItems.Add( + new SchemaNavigationItem(context.UrlPathPrefix, apiUrlSuffix, apiSchema, rootNavigation, aggCategoryNav) + ); } if (aggregateSchema.Value is not null) { var apiSchema = new ApiSchema(aggregateSchema.Key, "Aggregate", "aggregations", aggregateSchema.Value); - aggNavigationItems.Add(new SchemaNavigationItem(context.UrlPathPrefix, apiUrlSuffix, apiSchema, rootNavigation, aggCategoryNav)); + aggNavigationItems.Add( + new SchemaNavigationItem(context.UrlPathPrefix, apiUrlSuffix, apiSchema, rootNavigation, aggCategoryNav) + ); } aggCategoryNav.NavigationItems = aggNavigationItems; @@ -332,7 +332,8 @@ private string ResolveTagClassification(string? tag, XTagGroups? xTagGroups, Has _logger.LogWarning( "OpenAPI tag '{TagName}' is not listed in any x-tagGroups entry; navigation will group it under '{UnknownGroup}'.", tagName, - UnknownTagGroupName); + UnknownTagGroupName + ); } return UnknownTagGroupName; @@ -371,7 +372,8 @@ private static IReadOnlyDictionary BuildTagMonikerMap(IReadOnlyL { throw new InvalidOperationException( $"OpenAPI tag URL segment conflict: tags '{existing}' and '{name}' both normalize to the same path segment '{segment}'. " + - "Rename one of the tag names in the spec."); + "Rename one of the tag names in the spec." + ); } segmentToTagName[segment] = name; diff --git a/src/Elastic.ApiExplorer/OpenApiGenerator.cs b/src/Elastic.ApiExplorer/OpenApiGenerator.cs index 1508f00e69..c20f9fe366 100644 --- a/src/Elastic.ApiExplorer/OpenApiGenerator.cs +++ b/src/Elastic.ApiExplorer/OpenApiGenerator.cs @@ -37,7 +37,8 @@ public class OpenApiGenerator( BuildContext context, IMarkdownStringRenderer markdownStringRenderer, VersionIndexClient? versionIndexClient = null, - IOpenApiSpecificationReader? openApiReader = null) + IOpenApiSpecificationReader? openApiReader = null +) { private readonly ILogger _logger = logFactory.CreateLogger(); private readonly IFileSystem _writeFileSystem = context.WriteFileSystem; @@ -45,8 +46,11 @@ public class OpenApiGenerator( private readonly VersionIndexClient _versionIndexClient = versionIndexClient ?? new VersionIndexClient(); private readonly IOpenApiSpecificationReader _openApiReader = openApiReader ?? OpenApiReader.Instance; - public LandingNavigationItem CreateNavigation(string apiUrlSuffix, OpenApiDocument openApiDocument, ResolvedApiConfiguration? apiConfig = null) => - new ApiNavigationBuilder(_logger, context).CreateNavigation(apiUrlSuffix, openApiDocument, apiConfig); + public LandingNavigationItem CreateNavigation( + string apiUrlSuffix, + OpenApiDocument openApiDocument, + ResolvedApiConfiguration? apiConfig = null + ) => new ApiNavigationBuilder(_logger, context).CreateNavigation(apiUrlSuffix, openApiDocument, apiConfig); public async Task Generate(Cancel ctx = default) { @@ -76,8 +80,7 @@ public async Task> GenerateProducts(Cancel ctx = } catch (Exception ex) when (ex is not OperationCanceledException) { - context.Collector.EmitGlobalError( - $"API '{prefix}' could not be generated: {ex.Message}"); + context.Collector.EmitGlobalError($"API '{prefix}' could not be generated: {ex.Message}"); } } @@ -90,10 +93,7 @@ public async Task> GenerateProducts(Cancel ctx = public Task GenerateCatalog(IReadOnlyList entries, Cancel ctx = default) => entries.Count == 0 ? Task.CompletedTask : GenerateApiCatalog(entries, ctx); - private async Task GenerateProduct( - string prefix, - ResolvedApiConfiguration apiConfig, - Cancel ctx) + private async Task GenerateProduct(string prefix, ResolvedApiConfiguration apiConfig, Cancel ctx) { var versionedDocuments = await ResolveDocumentsForProduct(prefix, apiConfig, ctx).ConfigureAwait(false); if (versionedDocuments.Count == 0) @@ -102,18 +102,13 @@ public Task GenerateCatalog(IReadOnlyList entries, Cancel ctx = var monikers = versionedDocuments.Select(v => v.Version.Moniker).ToArray(); foreach (var versioned in versionedDocuments) { - var switcherItems = ApiVersionSwitcher.Build( - context.UrlPathPrefix, prefix, monikers, versioned.Version.Moniker); + var switcherItems = ApiVersionSwitcher.Build(context.UrlPathPrefix, prefix, monikers, versioned.Version.Moniker); var apiUrlSuffix = ApiUrlBuilder.ProductSuffix(prefix, versioned.Version.Moniker); - await GenerateApiProduct(apiUrlSuffix, versioned.Document, apiConfig, switcherItems, ctx) - .ConfigureAwait(false); + await GenerateApiProduct(apiUrlSuffix, versioned.Document, apiConfig, switcherItems, ctx).ConfigureAwait(false); } - var canonical = versionedDocuments.FirstOrDefault(v => v.Version.Moniker == "main") - ?? versionedDocuments[0]; - var title = canonical.Document.Info?.Title - ?? apiConfig.Product.DisplayName - ?? prefix; + var canonical = versionedDocuments.FirstOrDefault(v => v.Version.Moniker == "main") ?? versionedDocuments[0]; + var title = canonical.Document.Info?.Title ?? apiConfig.Product.DisplayName ?? prefix; var url = $"{ApiUrlBuilder.ProductRoot(context.UrlPathPrefix, prefix)}/"; return new ApiCatalogEntry(prefix, title, url); } @@ -125,18 +120,17 @@ await GenerateApiProduct(apiUrlSuffix, versioned.Document, apiConfig, switcherIt internal async Task> ResolveDocumentsForProduct( string apiKey, ResolvedApiConfiguration apiConfig, - Cancel ctx) + Cancel ctx + ) { var versionless = IsVersionlessProduct(apiConfig.Product); if (apiConfig.LocalSpecFile is { } localFile && versionless) return await ResolveLocalMainOnly(localFile).ConfigureAwait(false); - var versions = await _versionIndexClient.ResolveVersionsAsync( - context.Git, apiKey, apiConfig, context.Collector, ctx).ConfigureAwait(false); + var versions = + await _versionIndexClient.ResolveVersionsAsync(context.Git, apiKey, apiConfig, context.Collector, ctx).ConfigureAwait(false); - var versionsToRender = versionless - ? versions.Where(v => v.Moniker == "main").ToArray() - : [.. versions]; + var versionsToRender = versionless ? versions.Where(v => v.Moniker == "main").ToArray() : [.. versions]; if (versionsToRender.Length == 0) return []; @@ -144,7 +138,8 @@ internal async Task> ResolveDocumentsFor if (!versionless && versionsToRender.All(v => v.Moniker != "main") && versions.Count > 0) { context.Collector.EmitGlobalWarning( - $"Version index for API '{apiKey}' has no 'main' entry; the unversioned path will not be rendered."); + $"Version index for API '{apiKey}' has no 'main' entry; the unversioned path will not be rendered." + ); } var results = new List(versionsToRender.Length); @@ -166,28 +161,22 @@ private async Task> ResolveLocalMainOnly if (document is null) return []; - return - [ + return [ new VersionedOpenApiDocument( - new ResolvedApiVersion - { - Moniker = "main", - Version = "main", - IsLocal = true, - LocalFile = localFile - }, - document) + new ResolvedApiVersion { Moniker = "main", Version = "main", IsLocal = true, LocalFile = localFile }, + document + ) ]; } - private static bool IsVersionlessProduct(Product product) => - product.VersioningSystem?.IsVersionless == true; + private static bool IsVersionlessProduct(Product product) => product.VersioningSystem?.IsVersionless == true; private async Task ResolveDocumentForVersion( string apiKey, ResolvedApiConfiguration apiConfig, ResolvedApiVersion version, - Cancel ctx) + Cancel ctx + ) { if (version.IsLocal) return await _openApiReader.ReadAsync(version.LocalFile!).ConfigureAwait(false); @@ -199,10 +188,7 @@ private static bool IsVersionlessProduct(Product product) => return await _openApiReader.ReadAsync(stream, apiConfig.SpecFileName).ConfigureAwait(false); } - private static readonly OpenApiDocument CatalogDocument = new() - { - Info = new OpenApiInfo { Title = "API Explorer", Version = "1.0" } - }; + private static readonly OpenApiDocument CatalogDocument = new() { Info = new OpenApiInfo { Title = "API Explorer", Version = "1.0" } }; private async Task GenerateApiCatalog(IReadOnlyList entries, Cancel ctx) { @@ -226,7 +212,8 @@ private async Task GenerateApiProduct( OpenApiDocument openApiDocument, ResolvedApiConfiguration? apiConfig, IReadOnlyList versionSwitcherItems, - Cancel ctx) + Cancel ctx + ) { var navigation = CreateNavigation(prefix, openApiDocument, apiConfig); _logger.LogInformation("Generating OpenApiDocument {Title}", openApiDocument.Info?.Title ?? ""); @@ -249,7 +236,8 @@ private async Task RenderNavigationItems( ApiRenderContext renderContext, IsolatedBuildNavigationHtmlWriter navigationRenderer, INavigationItem currentNavigation, - Cancel ctx) + Cancel ctx + ) { if (currentNavigation is INodeNavigationItem node) { @@ -267,20 +255,20 @@ private async Task RenderNavigationItems( } } - private async Task Render(INavigationItem current, T page, ApiRenderContext renderContext, - IsolatedBuildNavigationHtmlWriter navigationRenderer, Cancel ctx) - where T : INavigationModel, IPageRenderer + private async Task Render( + INavigationItem current, + T page, + ApiRenderContext renderContext, + IsolatedBuildNavigationHtmlWriter navigationRenderer, + Cancel ctx + ) where T : INavigationModel, IPageRenderer { var outputFile = OutputFile(current); if (!outputFile.Directory!.Exists) outputFile.Directory.Create(); var navigationRenderResult = await navigationRenderer.RenderNavigation(current.NavigationRoot, current, ctx); - renderContext = renderContext with - { - CurrentNavigation = current, - NavigationHtml = navigationRenderResult.Html - }; + renderContext = renderContext with { CurrentNavigation = current, NavigationHtml = navigationRenderResult.Html }; await using var stream = _writeFileSystem.FileStream.New(outputFile.FullName, FileMode.OpenOrCreate); await page.RenderAsync(stream, renderContext, ctx); return outputFile; diff --git a/src/Elastic.ApiExplorer/Operations/OpenApiXReqAuthParser.cs b/src/Elastic.ApiExplorer/Operations/OpenApiXReqAuthParser.cs index 1be0958f4a..e304f0045a 100644 --- a/src/Elastic.ApiExplorer/Operations/OpenApiXReqAuthParser.cs +++ b/src/Elastic.ApiExplorer/Operations/OpenApiXReqAuthParser.cs @@ -32,7 +32,12 @@ public static class OpenApiXReqAuthParser { if (jne.Node is not JsonArray array) { - log?.LogWarning("Failed to parse {Extension} extension for operation {OperationId} on path {Path}: expected a JSON array", ExtensionKey, operationId, route); + log?.LogWarning( + "Failed to parse {Extension} extension for operation {OperationId} on path {Path}: expected a JSON array", + ExtensionKey, + operationId, + route + ); return null; } @@ -53,15 +58,19 @@ public static class OpenApiXReqAuthParser } catch (Exception ex) { - log?.LogWarning(ex, "Failed to parse {Extension} extension for operation {OperationId} on path {Path}", ExtensionKey, operationId, route); + log?.LogWarning( + ex, + "Failed to parse {Extension} extension for operation {OperationId} on path {Path}", + ExtensionKey, + operationId, + route + ); return null; } } private static string LineFromNode(JsonNode node) => node is JsonValue value - ? value.GetValueKind() == JsonValueKind.String - ? value.GetValue() ?? "" - : value.ToString() ?? "" + ? value.GetValueKind() == JsonValueKind.String ? value.GetValue() ?? "" : value.ToString() ?? "" : node.ToString() ?? ""; } diff --git a/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs b/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs index 4c0a149c1c..b6c2709124 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs +++ b/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs @@ -13,15 +13,17 @@ namespace Elastic.ApiExplorer.Operations; -public record ApiOperation(HttpMethod OperationType, OpenApiOperation Operation, string Route, IOpenApiPathItem Path, string ApiName) : IApiModel +public record ApiOperation( + HttpMethod OperationType, + OpenApiOperation Operation, + string Route, + IOpenApiPathItem Path, + string ApiName +) : IApiModel { public async Task RenderAsync(FileSystemStream stream, ApiRenderContext context, Cancel ctx = default) { - var viewModel = new OperationViewModel(context) - { - Operation = this, - Page = OperationPageModel.Create(this, context) - }; + var viewModel = new OperationViewModel(context) { Operation = this, Page = OperationPageModel.Create(this, context) }; var slice = OperationView.Create(viewModel); await slice.RenderAsync(stream, cancellationToken: ctx); } @@ -58,5 +60,4 @@ IApiGroupingNavigationItem parent public INodeNavigationItem? Parent { get; set; } public int NavigationIndex { get; set; } - } diff --git a/src/Elastic.ApiExplorer/Operations/OperationPageModel.cs b/src/Elastic.ApiExplorer/Operations/OperationPageModel.cs index 00179027b1..c0280b0bf9 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationPageModel.cs +++ b/src/Elastic.ApiExplorer/Operations/OperationPageModel.cs @@ -109,10 +109,9 @@ public static OperationPageModel Create(ApiOperation apiOperation, ApiRenderCont var showRequestExamples = requestExamples is { Count: > 0 } && !(requestExamples.Count == 1 && codeSamples.Count > 0); var showResponseExamples = responseExamples is { Count: > 0 }; - var examplesAnchor = codeSamples.Count > 0 ? "code-examples" - : requestExamples is { Count: > 0 } ? "request-examples" - : responseExamples is { Count: > 0 } ? "response-examples" - : null; + var examplesAnchor = codeSamples.Count > 0 + ? "code-examples" + : requestExamples is { Count: > 0 } ? "request-examples" : responseExamples is { Count: > 0 } ? "response-examples" : null; var requestContentEntry = operation.RequestBody?.Content?.FirstOrDefault(); var requestSchema = requestContentEntry?.Value?.Schema; @@ -132,10 +131,11 @@ public static OperationPageModel Create(ApiOperation apiOperation, ApiRenderCont Servers = operation.Servers is { Count: > 0 } ? operation.Servers : document.Servers, Overloads = ResolveOverloads(context), PathParameters = operation.Parameters?.Where(p => p.In == ParameterLocation.Path).ToArray() ?? [], - QueryParameters = (operation.Parameters ?? []) - .Where(p => p.In == ParameterLocation.Query) - .Select(p => BuildQueryParameter(p, analyzer, builder)) - .ToArray(), + QueryParameters = + (operation.Parameters ?? []) + .Where(p => p.In == ParameterLocation.Query) + .Select(p => BuildQueryParameter(p, analyzer, builder)) + .ToArray(), RequestContentType = requestContentEntry?.Key ?? "application/json", RequestProperties = requestSchema is not null ? builder.BuildPropertyList(requestSchema, new PropertyTreeScope { Prefix = "req", IsRequest = true }) @@ -151,24 +151,37 @@ public static OperationPageModel Create(ApiOperation apiOperation, ApiRenderCont }; } - private static IReadOnlyList MapExamples(IDictionary? examples, Func renderMarkdown) => + private static IReadOnlyList MapExamples( + IDictionary? examples, + Func renderMarkdown + ) => examples is null ? [] - : examples.Select(e => new ExampleDisplay( - string.IsNullOrEmpty(e.Value?.Summary) ? e.Key : e.Value.Summary, - string.IsNullOrEmpty(e.Value?.Description) ? null : renderMarkdown(e.Value.Description), - e.Value?.Value?.ToString(), - string.IsNullOrEmpty(e.Value?.ExternalValue) ? null : e.Value.ExternalValue)).ToArray(); + : examples.Select( + e => + new ExampleDisplay( + string.IsNullOrEmpty(e.Value?.Summary) ? e.Key : e.Value.Summary, + string.IsNullOrEmpty(e.Value?.Description) ? null : renderMarkdown(e.Value.Description), + e.Value?.Value?.ToString(), + string.IsNullOrEmpty(e.Value?.ExternalValue) ? null : e.Value.ExternalValue + ) + ).ToArray(); private static IReadOnlyCollection ResolveOverloads(ApiRenderContext context) { - if (context.CurrentNavigation.Parent is EndpointNavigationItem { NavigationItems.Count: > 0 } parent - && parent.NavigationItems.All(n => n.Hidden)) + if ( + context.CurrentNavigation.Parent is EndpointNavigationItem { NavigationItems.Count: > 0 } parent && + parent.NavigationItems.All(n => n.Hidden) + ) return parent.NavigationItems; return context.CurrentNavigation is OperationNavigationItem self ? [self] : []; } - private static ApiQueryParameter BuildQueryParameter(IOpenApiParameter parameter, SchemaAnalyzer analyzer, ApiPropertyTreeBuilder builder) + private static ApiQueryParameter BuildQueryParameter( + IOpenApiParameter parameter, + SchemaAnalyzer analyzer, + ApiPropertyTreeBuilder builder + ) { var schema = parameter.Schema; return new ApiQueryParameter @@ -177,9 +190,10 @@ private static ApiQueryParameter BuildQueryParameter(IOpenApiParameter parameter Type = schema is not null ? builder.Describe(schema) : null, Constraints = schema is not null ? ApiPropertyTreeBuilder.BuildConstraints(schema) : [], EnumValues = CollectEnumValues(schema, analyzer), - UnionOptions = CollectUnionOptionNames(schema, analyzer) - .Select(n => new UnionBadge(n, ApiPropertyTreeBuilder.IsTypeOptionBadge(n))) - .ToArray() + UnionOptions = + CollectUnionOptionNames(schema, analyzer).Select( + n => new UnionBadge(n, ApiPropertyTreeBuilder.IsTypeOptionBadge(n)) + ).ToArray() }; } @@ -198,18 +212,14 @@ private static IReadOnlyList CollectEnumValues(IOpenApiSchema? schema, S return enumValues; // Check for oneOf/anyOf with string literals (union enums) - var unionSchemas = resolved?.OneOf is { Count: > 0 } ? resolved.OneOf - : resolved?.AnyOf is { Count: > 0 } ? resolved.AnyOf - : null; + var unionSchemas = resolved?.OneOf is { Count: > 0 } ? resolved.OneOf : resolved?.AnyOf is { Count: > 0 } ? resolved.AnyOf : null; if (unionSchemas is not null) { enumValues.AddRange( - unionSchemas - .Select(analyzer.ResolveSchema) + unionSchemas.Select(analyzer.ResolveSchema) .Where(r => r?.Enum is { Count: > 0 }) - .SelectMany(r => r!.Enum! - .Select(e => e?.ToString()?.Trim('"') ?? "") - .Where(e => !string.IsNullOrEmpty(e)))); + .SelectMany(r => r!.Enum!.Select(e => e?.ToString()?.Trim('"') ?? "").Where(e => !string.IsNullOrEmpty(e))) + ); } return enumValues; @@ -225,7 +235,11 @@ private static IReadOnlyList CollectUnionOptionNames(IOpenApiSchema? sch return []; } - private static IReadOnlyList BuildResponses(OpenApiOperation operation, SchemaAnalyzer analyzer, ApiPropertyTreeBuilder builder) + private static IReadOnlyList BuildResponses( + OpenApiOperation operation, + SchemaAnalyzer analyzer, + ApiPropertyTreeBuilder builder + ) { if (operation.Responses is not { Count: > 0 }) return []; @@ -241,9 +255,9 @@ private static IReadOnlyList BuildResponses(OpenApiOperation operat StatusCode = statusCode, Response = response, FirstContentType = response.Content is { Count: > 0 } ? response.Content.First().Key : null, - StatusClass = statusCode.StartsWith('2') ? "success" - : statusCode.StartsWith('4') || statusCode.StartsWith('5') ? "error" - : "info", + StatusClass = statusCode.StartsWith('2') + ? "success" + : statusCode.StartsWith('4') || statusCode.StartsWith('5') ? "error" : "info", Contents = response.Content is null ? [] : response.Content @@ -253,12 +267,15 @@ private static IReadOnlyList BuildResponses(OpenApiOperation operat Headers = response.Headers is null ? [] : response.Headers - .Select(h => new ApiResponseHeader - { - Name = h.Key, - Header = h.Value, - Type = h.Value?.Schema is not null ? builder.Describe(h.Value.Schema) : null - }) + .Select( + h => + new ApiResponseHeader + { + Name = h.Key, + Header = h.Value, + Type = h.Value?.Schema is not null ? builder.Describe(h.Value.Schema) : null + } + ) .ToArray() }); } @@ -267,7 +284,12 @@ private static IReadOnlyList BuildResponses(OpenApiOperation operat } private static ApiResponseContent BuildResponseContent( - string contentType, IOpenApiSchema responseSchema, string statusCode, SchemaAnalyzer analyzer, ApiPropertyTreeBuilder builder) + string contentType, + IOpenApiSchema responseSchema, + string statusCode, + SchemaAnalyzer analyzer, + ApiPropertyTreeBuilder builder + ) { var scope = new PropertyTreeScope { Prefix = $"res-{statusCode}" }; var properties = builder.BuildPropertyList(responseSchema, scope); diff --git a/src/Elastic.ApiExplorer/Types/SchemaNavigationItem.cs b/src/Elastic.ApiExplorer/Types/SchemaNavigationItem.cs index 3334c1024a..a211dce6f1 100644 --- a/src/Elastic.ApiExplorer/Types/SchemaNavigationItem.cs +++ b/src/Elastic.ApiExplorer/Types/SchemaNavigationItem.cs @@ -18,11 +18,7 @@ public record ApiSchema(string SchemaId, string DisplayName, string Category, IO { public async Task RenderAsync(FileSystemStream stream, ApiRenderContext context, Cancel ctx = default) { - var viewModel = new SchemaViewModel(context) - { - Schema = this, - Page = SchemaPageModel.Create(this, context) - }; + var viewModel = new SchemaViewModel(context) { Schema = this, Page = SchemaPageModel.Create(this, context) }; var slice = SchemaView.Create(viewModel); await slice.RenderAsync(stream, cancellationToken: ctx); } diff --git a/src/Elastic.ApiExplorer/Types/SchemaPageModel.cs b/src/Elastic.ApiExplorer/Types/SchemaPageModel.cs index 478cd8dfdd..d4ac8b8591 100644 --- a/src/Elastic.ApiExplorer/Types/SchemaPageModel.cs +++ b/src/Elastic.ApiExplorer/Types/SchemaPageModel.cs @@ -2,7 +2,6 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information - using Elastic.ApiExplorer.Components.PropertyTree; using Elastic.ApiExplorer.Infrastructure; using Elastic.ApiExplorer.Model; @@ -63,9 +62,7 @@ public static SchemaPageModel Create(ApiSchema schema, ApiRenderContext context) ? builder.BuildUnionVariantsForSchemas(openApiSchema.AnyOf, "anyof", rootAncestors) ?? ApiUnionVariants.Empty : null, Properties = builder.BuildPropertyList(openApiSchema, new PropertyTreeScope { Prefix = "", Ancestors = rootAncestors }), - AdditionalPropertiesType = openApiSchema.AdditionalProperties is { } addProps - ? builder.Describe(addProps) - : null + AdditionalPropertiesType = openApiSchema.AdditionalProperties is { } addProps ? builder.Describe(addProps) : null }; } } diff --git a/src/Elastic.ApiExplorer/Types/SchemaViewModel.cs b/src/Elastic.ApiExplorer/Types/SchemaViewModel.cs index 3492aeeeec..fefe9e5f0a 100644 --- a/src/Elastic.ApiExplorer/Types/SchemaViewModel.cs +++ b/src/Elastic.ApiExplorer/Types/SchemaViewModel.cs @@ -69,8 +69,7 @@ private IEnumerable GetSchemaPropertyNames(IOpenApiSchema? schema) return schemaRef.Properties.Keys; var refId = schemaRef.Reference?.Id; - if (!string.IsNullOrEmpty(refId) && - Document.Components?.Schemas?.TryGetValue(refId, out var resolvedSchema) == true) + if (!string.IsNullOrEmpty(refId) && Document.Components?.Schemas?.TryGetValue(refId, out var resolvedSchema) == true) { return GetSchemaPropertyNames(resolvedSchema); } @@ -109,8 +108,7 @@ private bool HasSchemaProperties(IOpenApiSchema? schema) // Try resolving via Reference.Id var refId = schemaRef.Reference?.Id; - if (!string.IsNullOrEmpty(refId) && - Document.Components?.Schemas?.TryGetValue(refId, out var resolvedSchema) == true) + if (!string.IsNullOrEmpty(refId) && Document.Components?.Schemas?.TryGetValue(refId, out var resolvedSchema) == true) { return HasSchemaProperties(resolvedSchema); } diff --git a/src/Elastic.Codex/Building/CodexBuildService.cs b/src/Elastic.Codex/Building/CodexBuildService.cs index 534683e3a9..6c93f528a7 100644 --- a/src/Elastic.Codex/Building/CodexBuildService.cs +++ b/src/Elastic.Codex/Building/CodexBuildService.cs @@ -35,9 +35,7 @@ namespace Elastic.Codex.Building; /// /// Service for building all documentation sets in a codex. /// -public class CodexBuildService( - ILoggerFactory logFactory, - IConfigurationContext configurationContext) : IService +public class CodexBuildService(ILoggerFactory logFactory, IConfigurationContext configurationContext) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); @@ -51,7 +49,8 @@ public async Task BuildAll( CodexCloneResult cloneResult, ScopedFileSystem fileSystem, Cancel ctx, - IReadOnlySet? exporters = null) + IReadOnlySet? exporters = null + ) { var outputDir = context.OutputDirectory; if (outputDir.Exists) @@ -62,8 +61,7 @@ public async Task BuildAll( outputDir.Create(); - _logger.LogInformation("Building {Count} documentation sets to {Directory}", - cloneResult.Checkouts.Count, outputDir.FullName); + _logger.LogInformation("Building {Count} documentation sets to {Directory}", cloneResult.Checkouts.Count, outputDir.FullName); var documentationSets = new Dictionary(); var buildContexts = new List(); @@ -87,7 +85,8 @@ public async Task BuildAll( context.Configuration, cloneResult.DocumentationSetReferences, new CodexDocumentationContext(context), - documentationSets); + documentationSets + ); // Phase 3: Build each documentation set // When exporters are specified (e.g., Elasticsearch), create a single shared exporter @@ -108,7 +107,13 @@ public async Task BuildAll( { var buildResult = await BuildDocumentationSet(context, buildContext, sharedExporters, ctx); if (buildResult is { Success: true } && buildResult.Redirects.Count > 0) - CollectRedirects(redirects, buildResult.Redirects, buildContext.Checkout.Reference.ResolvedRepoName, buildContext.DocumentationSet.CrossLinkResolver, context); + CollectRedirects( + redirects, + buildResult.Redirects, + buildContext.Checkout.Reference.ResolvedRepoName, + buildContext.DocumentationSet.CrossLinkResolver, + context + ); } if (effectiveExporters.Contains(Exporter.Redirects) && redirects.Count > 0) @@ -141,7 +146,8 @@ public async Task BuildAll( CodexCheckout checkout, ScopedFileSystem fileSystem, ILinkIndexReader codexLinkIndexReader, - Cancel ctx) + Cancel ctx + ) { _logger.LogInformation("Loading documentation set: {Name}", checkout.Reference.Name); @@ -157,9 +163,7 @@ public async Task BuildAll( : fileSystem.Path.Join(context.OutputDirectory.FullName, sitePrefix, "r", repoName); // Build URL path prefix: /r/{repoName} or /{sitePrefix}/r/{repoName} - var pathPrefix = string.IsNullOrEmpty(sitePrefix) - ? $"/r/{repoName}" - : $"/{sitePrefix}/r/{repoName}"; + var pathPrefix = string.IsNullOrEmpty(sitePrefix) ? $"/r/{repoName}" : $"/{sitePrefix}/r/{repoName}"; // Create git checkout information var git = new GitCheckoutInformation @@ -176,9 +180,7 @@ public async Task BuildAll( // Parse canonical base URL from config for frontmatter URLs, canonical links, and report-issue var canonicalBaseUrl = !string.IsNullOrWhiteSpace(context.Configuration.CanonicalBaseUrl) && - Uri.TryCreate(context.Configuration.CanonicalBaseUrl, UriKind.Absolute, out var parsed) - ? parsed - : null; + Uri.TryCreate(context.Configuration.CanonicalBaseUrl, UriKind.Absolute, out var parsed) ? parsed : null; // Repository clone root must be BuildContext `source`: FindGitRoot(..., ceiling: rootFolder) only // discovers .git inside that ceiling (#3115). Using DocsDirectory alone would cap the ceiling @@ -186,12 +188,10 @@ public async Task BuildAll( // The docset file itself is passed explicitly so build reuses the docset clone discovery already // selected (which may prefer a non-default path such as `docs-dev/`), rather than rediscovering // one from the repository root and always landing on `docs/`. - var docFs = DocumentationFileSystem.Resolve(checkout.RepositoryDirectory, new DocumentationScopeOptions - { - Output = outputPath, - Git = git, - ConfigurationFile = checkout.DocsetFile.FullName, - }); + var docFs = DocumentationFileSystem.Resolve( + checkout.RepositoryDirectory, + new DocumentationScopeOptions { Output = outputPath, Git = git, ConfigurationFile = checkout.DocsetFile.FullName, } + ); var buildContext = new BuildContext(context.Collector, docFs, configurationContext) { UrlPathPrefix = pathPrefix, @@ -210,7 +210,8 @@ public async Task BuildAll( var fetcher = new DocSetConfigurationCrossLinkFetcher( logFactory, buildContext.Configuration, - codexLinkIndexReader: buildContext.Configuration.Registry != DocSetRegistry.Public ? codexLinkIndexReader : null); + codexLinkIndexReader: buildContext.Configuration.Registry != DocSetRegistry.Public ? codexLinkIndexReader : null + ); var crossLinks = await fetcher.FetchCrossLinks(ctx); if (crossLinks.CodexRepositories is not null) codexRepos.UnionWith(crossLinks.CodexRepositories); @@ -234,8 +235,10 @@ public async Task BuildAll( } catch (Exception ex) { - context.Collector.EmitError(context.ConfigurationPath, - $"Failed to load documentation set '{checkout.Reference.Name}': {ex.Message}"); + context.Collector.EmitError( + context.ConfigurationPath, + $"Failed to load documentation set '{checkout.Reference.Name}': {ex.Message}" + ); _logger.LogError(ex, "Failed to load documentation set {Name}", checkout.Reference.Name); return null; } @@ -245,7 +248,8 @@ private async Task BuildDocumentationSet( CodexContext context, CodexDocumentationSetBuildContext buildContext, IMarkdownExporter[]? sharedExporters, - Cancel ctx) + Cancel ctx + ) { _logger.LogInformation("Building documentation set: {Name}", buildContext.Checkout.Reference.Name); @@ -268,7 +272,8 @@ private async Task BuildDocumentationSet( logFactory, documentationSet, markdownExporters: allExporters, - pageViewFactory: new CodexPageViewFactory(context.Configuration.Title, codexBreadcrumbs)); + pageViewFactory: new CodexPageViewFactory(context.Configuration.Title, codexBreadcrumbs) + ); var result = await generator.GenerateAll(ctx); @@ -285,8 +290,10 @@ private async Task BuildDocumentationSet( } catch (Exception ex) { - context.Collector.EmitError(context.ConfigurationPath, - $"Failed to build documentation set '{buildContext.Checkout.Reference.Name}': {ex.Message}"); + context.Collector.EmitError( + context.ConfigurationPath, + $"Failed to build documentation set '{buildContext.Checkout.Reference.Name}': {ex.Message}" + ); _logger.LogError(ex, "Failed to build documentation set {Name}", buildContext.Checkout.Reference.Name); return new BuildDocumentationSetResult(false, new Dictionary()); } @@ -316,7 +323,8 @@ private static void CollectRedirects( IReadOnlyDictionary redirects, string repository, ICrossLinkResolver linkResolver, - CodexContext context) + CodexContext context + ) { if (redirects.Count == 0) return; @@ -337,30 +345,38 @@ string Resolve(string path) { Uri? uri; if (Uri.IsWellFormedUriString(path, UriKind.Absolute)) // Cross-repo links + { - _ = linkResolver.TryResolve( - specificErrorMessage => context.Collector.EmitError(context.ConfigurationPath.FullName, $"An error occurred while resolving cross-link {path}", specificErrorMessage), - new Uri(path), - out uri); + _ = + linkResolver.TryResolve( + specificErrorMessage => + context.Collector.EmitError( + context.ConfigurationPath.FullName, + $"An error occurred while resolving cross-link {path}", + specificErrorMessage + ), + new Uri(path), + out uri + ); } else // Relative links + { - uri = linkResolver.UriResolver.Resolve(new Uri($"{repository}://{path}"), - CrossLinkResolver.ToTargetUrlPath(path)); + uri = linkResolver.UriResolver.Resolve(new Uri($"{repository}://{path}"), CrossLinkResolver.ToTargetUrlPath(path)); } - return uri is null - ? string.Empty - : uri.IsAbsoluteUri ? uri.AbsolutePath : uri.OriginalString; + return uri is null ? string.Empty : uri.IsAbsoluteUri ? uri.AbsolutePath : uri.OriginalString; } } private async Task OutputRedirectsAsync(CodexContext context, Dictionary redirects, Cancel ctx) { - var uniqueRedirects = redirects - .Where(x => !x.Key.TrimEnd('/').Equals(x.Value.TrimEnd('/'), StringComparison.OrdinalIgnoreCase)) - .ToDictionary(); - var redirectsFile = context.WriteFileSystem.FileInfo.New(context.WriteFileSystem.Path.Join(context.OutputDirectory.FullName, "redirects.json")); + var uniqueRedirects = redirects.Where( + x => !x.Key.TrimEnd('/').Equals(x.Value.TrimEnd('/'), StringComparison.OrdinalIgnoreCase) + ).ToDictionary(); + var redirectsFile = context.WriteFileSystem + .FileInfo + .New(context.WriteFileSystem.Path.Join(context.OutputDirectory.FullName, "redirects.json")); _logger.LogInformation("Writing {Count} resolved redirects to {Path}", uniqueRedirects.Count, redirectsFile.FullName); var redirectsJson = JsonSerializer.Serialize(uniqueRedirects, SourceGenerationContext.Default.DictionaryStringString); @@ -369,7 +385,8 @@ private async Task OutputRedirectsAsync(CodexContext context, Dictionary ResolveCodexBreadcrumbs( CodexContext context, - CodexDocumentationSetBuildContext buildContext) + CodexDocumentationSetBuildContext buildContext + ) { var reference = buildContext.Checkout.Reference; var sitePrefix = context.Configuration.SitePrefix?.Trim('/') ?? ""; @@ -386,21 +403,24 @@ private static IReadOnlyList ResolveCodexBreadcrumbs( var groupUrl = string.IsNullOrEmpty(sitePrefix) ? $"/g/{groupId}" : $"/{sitePrefix}/g/{groupId}"; var groupDef = context.Configuration.Groups.FirstOrDefault(g => g.Id == groupId); var groupTitle = groupDef?.Name ?? groupId; - return [new CodexBreadcrumb("Home", homeUrl), new CodexBreadcrumb(groupTitle, groupUrl), new CodexBreadcrumb(docSetTitle, docSetUrl)]; + return [ + new CodexBreadcrumb("Home", homeUrl), + new CodexBreadcrumb(groupTitle, groupUrl), + new CodexBreadcrumb(docSetTitle, docSetUrl) + ]; } private async Task GenerateCodexPages( CodexContext context, BuildContext docSetBuildContext, CodexNavigation codexNavigation, - Cancel ctx) + Cancel ctx + ) { _logger.LogInformation("Generating codex pages"); // Pre-compute codex site root for HTMX - var siteRootPath = string.IsNullOrEmpty(context.Configuration.SitePrefix) - ? "/" - : $"/{context.Configuration.SitePrefix.Trim('/')}/"; + var siteRootPath = string.IsNullOrEmpty(context.Configuration.SitePrefix) ? "/" : $"/{context.Configuration.SitePrefix.Trim('/')}/"; // Create a codex-specific build context using the doc set's context as a base // but with codex-specific URL prefix @@ -428,7 +448,8 @@ private async Task GenerateCodexPages( public record CodexBuildResult( CodexNavigation Navigation, IReadOnlyList DocumentationSets, - CodexGenerator? CodexGenerator = null); + CodexGenerator? CodexGenerator = null +); /// /// Result of building a documentation set, including redirects for aggregation in the codex build. @@ -440,10 +461,7 @@ public record BuildDocumentationSetResult(bool Success, IReadOnlyDictionary /// Build context for a single documentation set within the codex. /// -public record CodexDocumentationSetBuildContext( - CodexCheckout Checkout, - BuildContext BuildContext, - DocumentationSet DocumentationSet); +public record CodexDocumentationSetBuildContext(CodexCheckout Checkout, BuildContext BuildContext, DocumentationSet DocumentationSet); /// /// Documentation context adapter for codex navigation creation. @@ -466,6 +484,5 @@ internal sealed class CodexDocumentationContext(CodexContext codexContext) : ICo public BuildType BuildType => BuildType.Codex; /// - public void EmitError(string message) => - codexContext.Collector.EmitError(codexContext.ConfigurationPath, message); + public void EmitError(string message) => codexContext.Collector.EmitError(codexContext.ConfigurationPath, message); } diff --git a/src/Elastic.Codex/CodexConfigurationLoader.cs b/src/Elastic.Codex/CodexConfigurationLoader.cs index d314ea2ee7..597328b394 100644 --- a/src/Elastic.Codex/CodexConfigurationLoader.cs +++ b/src/Elastic.Codex/CodexConfigurationLoader.cs @@ -21,7 +21,8 @@ internal static bool TryLoad( string originalPath, IDiagnosticsCollector collector, out CodexConfiguration config, - out string environment) + out string environment + ) { environment = string.Empty; if (!TryLoadCore(configFile, originalPath, collector, out config)) @@ -29,8 +30,7 @@ internal static bool TryLoad( if (string.IsNullOrWhiteSpace(config.Environment)) { - collector.EmitGlobalError( - "Codex configuration must specify an 'environment' (e.g., 'internal', 'security')."); + collector.EmitGlobalError("Codex configuration must specify an 'environment' (e.g., 'internal', 'security')."); return false; } @@ -46,14 +46,15 @@ internal static bool TryLoad( IFileInfo configFile, string originalPath, IDiagnosticsCollector collector, - out CodexConfiguration config) => - TryLoadCore(configFile, originalPath, collector, out config); + out CodexConfiguration config + ) => TryLoadCore(configFile, originalPath, collector, out config); private static bool TryLoadCore( IFileInfo configFile, string originalPath, IDiagnosticsCollector collector, - out CodexConfiguration config) + out CodexConfiguration config + ) { config = default!; try @@ -69,8 +70,7 @@ private static bool TryLoadCore( } catch (Exception ex) { - collector.EmitGlobalError( - $"Failed to read codex configuration '{originalPath}': {ex.Message}", ex); + collector.EmitGlobalError($"Failed to read codex configuration '{originalPath}': {ex.Message}", ex); return false; } } diff --git a/src/Elastic.Codex/CodexFileSystem.cs b/src/Elastic.Codex/CodexFileSystem.cs index 4e1b16e8bf..ef2d37a843 100644 --- a/src/Elastic.Codex/CodexFileSystem.cs +++ b/src/Elastic.Codex/CodexFileSystem.cs @@ -26,16 +26,14 @@ public class CodexFileSystem : CheckoutsFileSystem /// Full path to the codex configuration file. Its directory is used to locate the git root. /// Optional explicit output directory. /// Underlying filesystem — defaults to the physical filesystem when . - public CodexFileSystem(string config, string? output = null, IFileSystem? inner = null) - : this(inner ?? Physical, config, output, inner) - { } + public CodexFileSystem(string config, string? output = null, IFileSystem? inner = null) : this(inner ?? Physical, config, output, inner) { } - private CodexFileSystem(IFileSystem fs, string config, string? output, IFileSystem? inner) - : base( + private CodexFileSystem(IFileSystem fs, string config, string? output, IFileSystem? inner) : base( root: fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), output: output is not null ? fs.DirectoryInfo.New(output) : null, inner: inner, - extraRoots: [Paths.FindGitRoot(fs.DirectoryInfo.New(fs.Path.GetDirectoryName(config)!))?.FullName ?? fs.Path.GetDirectoryName(config)!] - ) - => ConfigurationFile = FileInfo.New(config); + extraRoots: [ + Paths.FindGitRoot(fs.DirectoryInfo.New(fs.Path.GetDirectoryName(config)!))?.FullName ?? fs.Path.GetDirectoryName(config)! + ] + ) => ConfigurationFile = FileInfo.New(config); } diff --git a/src/Elastic.Codex/CodexGenerator.cs b/src/Elastic.Codex/CodexGenerator.cs index bb556deda5..f000b8d234 100644 --- a/src/Elastic.Codex/CodexGenerator.cs +++ b/src/Elastic.Codex/CodexGenerator.cs @@ -73,23 +73,19 @@ private async Task ExtractEmbeddedStaticResources(Cancel ctx) _logger.LogInformation("Copying static files to codex output directory"); var assembly = typeof(EmbeddedOrPhysicalFileProvider).Assembly; - foreach (var resourceName in assembly.GetManifestResourceNames() - .Where(r => r.StartsWith("Elastic.Documentation.Site._static.", StringComparison.Ordinal))) + foreach (var resourceName in assembly.GetManifestResourceNames().Where( + r => r.StartsWith("Elastic.Documentation.Site._static.", StringComparison.Ordinal) + )) { await using var resourceStream = assembly.GetManifestResourceStream(resourceName); if (resourceStream == null) continue; // Convert resource name to file path: Elastic.Documentation.Site._static.file.ext -> _static/file.ext - var path = resourceName - .Replace("Elastic.Documentation.Site.", "") - .Replace("_static.", $"_static{Path.DirectorySeparatorChar}"); + var path = resourceName.Replace("Elastic.Documentation.Site.", "").Replace("_static.", $"_static{Path.DirectorySeparatorChar}"); // Output to codex's URL prefix directory (e.g., internal-docs/_static/) - var outputPath = Path.Join( - _outputDirectory.FullName, - context.UrlPathPrefix?.Trim('/') ?? string.Empty, - path); + var outputPath = Path.Join(_outputDirectory.FullName, context.UrlPathPrefix?.Trim('/') ?? string.Empty, path); var outputFile = _writeFileSystem.FileInfo.New(outputPath); if (outputFile.Directory is { Exists: false }) @@ -105,23 +101,14 @@ private async Task RenderLandingPage( CodexNavigation codexNavigation, CodexRenderContext renderContext, CodexNavigationHtmlWriter navigationRenderer, - CancellationToken ctx) + CancellationToken ctx + ) { - var navigationRenderResult = await navigationRenderer.RenderNavigation( - codexNavigation, - codexNavigation.Index, - ctx); + var navigationRenderResult = await navigationRenderer.RenderNavigation(codexNavigation, codexNavigation.Index, ctx); - renderContext = renderContext with - { - CurrentNavigation = codexNavigation.Index, - NavigationHtml = navigationRenderResult.Html - }; + renderContext = renderContext with { CurrentNavigation = codexNavigation.Index, NavigationHtml = navigationRenderResult.Html }; - var viewModel = new LandingViewModel(renderContext) - { - IndexPage = (CodexIndexPage)codexNavigation.Index.Model - }; + var viewModel = new LandingViewModel(renderContext) { IndexPage = (CodexIndexPage)codexNavigation.Index.Model }; var outputFile = GetOutputFile(codexNavigation.Url); if (!outputFile.Directory!.Exists) @@ -138,23 +125,14 @@ private async Task RenderGroupLandingPage( GroupNavigation groupNav, CodexRenderContext renderContext, CodexNavigationHtmlWriter navigationRenderer, - CancellationToken ctx) + CancellationToken ctx + ) { - var navigationRenderResult = await navigationRenderer.RenderNavigation( - groupNav, - groupNav.Index, - ctx); + var navigationRenderResult = await navigationRenderer.RenderNavigation(groupNav, groupNav.Index, ctx); - renderContext = renderContext with - { - CurrentNavigation = groupNav.Index, - NavigationHtml = navigationRenderResult.Html - }; + renderContext = renderContext with { CurrentNavigation = groupNav.Index, NavigationHtml = navigationRenderResult.Html }; - var viewModel = new GroupLandingViewModel(renderContext) - { - Group = groupNav - }; + var viewModel = new GroupLandingViewModel(renderContext) { Group = groupNav }; var outputFile = GetOutputFile(groupNav.Url); if (!outputFile.Directory!.Exists) @@ -179,5 +157,7 @@ private IFileInfo GetOutputFile(string url) /// /// Navigation HTML writer for codex builds. /// -internal sealed class CodexNavigationHtmlWriter(BuildContext context, CodexNavigation codexNavigation) - : IsolatedBuildNavigationHtmlWriter(context, codexNavigation); +internal sealed class CodexNavigationHtmlWriter(BuildContext context, CodexNavigation codexNavigation) : IsolatedBuildNavigationHtmlWriter( + context, + codexNavigation +); diff --git a/src/Elastic.Codex/CodexViewModel.cs b/src/Elastic.Codex/CodexViewModel.cs index e64802dccc..58c8118a58 100644 --- a/src/Elastic.Codex/CodexViewModel.cs +++ b/src/Elastic.Codex/CodexViewModel.cs @@ -58,13 +58,9 @@ public string Static(string path) var staticPath = $"_static/{path.TrimStart('/')}"; var contentHash = StaticFileContentHashProvider.GetContentHash(path.TrimStart('/')); - var fullPath = string.IsNullOrEmpty(StaticPathPrefix) - ? $"/{staticPath}" - : $"{StaticPathPrefix}/{staticPath}"; + var fullPath = string.IsNullOrEmpty(StaticPathPrefix) ? $"/{staticPath}" : $"{StaticPathPrefix}/{staticPath}"; - return string.IsNullOrEmpty(contentHash) - ? fullPath - : $"{fullPath}?v={contentHash}"; + return string.IsNullOrEmpty(contentHash) ? fullPath : $"{fullPath}?v={contentHash}"; } /// diff --git a/src/Elastic.Codex/Indexing/CodexIndexService.cs b/src/Elastic.Codex/Indexing/CodexIndexService.cs index 5f45c452cb..a32c2c65da 100644 --- a/src/Elastic.Codex/Indexing/CodexIndexService.cs +++ b/src/Elastic.Codex/Indexing/CodexIndexService.cs @@ -18,10 +18,7 @@ namespace Elastic.Codex.Indexing; /// Configures ES endpoint options using the shared /// and delegates to with the Elasticsearch exporter. /// -public class CodexIndexService( - ILoggerFactory logFactory, - IConfigurationContext configurationContext -) : IService +public class CodexIndexService(ILoggerFactory logFactory, IConfigurationContext configurationContext) : IService { /// /// Index codex documentation to Elasticsearch. @@ -31,7 +28,8 @@ public async Task Index( CodexCloneResult cloneResult, ScopedFileSystem fileSystem, ElasticsearchIndexOptions esOptions, - Cancel ctx = default) + Cancel ctx = default + ) { var cfg = configurationContext.Endpoints.Elasticsearch; await ElasticsearchEndpointConfigurator.ApplyAsync(cfg, esOptions, codexContext.Collector, fileSystem, ctx); diff --git a/src/Elastic.Codex/Landing/CodexCardModel.cs b/src/Elastic.Codex/Landing/CodexCardModel.cs index 44ea879dc3..8070ee8119 100644 --- a/src/Elastic.Codex/Landing/CodexCardModel.cs +++ b/src/Elastic.Codex/Landing/CodexCardModel.cs @@ -25,13 +25,14 @@ public record CodexCardModel /// /// Builds the card model for a documentation set, so every docset card renders the same fields. /// - public static CodexCardModel FromDocumentationSet(CodexDocumentationSetInfo docSet) => new() - { - Url = docSet.Url, - Title = docSet.Title ?? docSet.Name, - Description = docSet.Description, - Icon = docSet.Icon, - PageCount = docSet.PageCount, - RepoPath = docSet.RepoPath, - }; + public static CodexCardModel FromDocumentationSet(CodexDocumentationSetInfo docSet) => + new() + { + Url = docSet.Url, + Title = docSet.Title ?? docSet.Name, + Description = docSet.Description, + Icon = docSet.Icon, + PageCount = docSet.PageCount, + RepoPath = docSet.RepoPath, + }; } diff --git a/src/Elastic.Codex/Landing/LandingViewModel.cs b/src/Elastic.Codex/Landing/LandingViewModel.cs index ef0de45a06..26e68cf143 100644 --- a/src/Elastic.Codex/Landing/LandingViewModel.cs +++ b/src/Elastic.Codex/Landing/LandingViewModel.cs @@ -21,15 +21,12 @@ public class LandingViewModel(CodexRenderContext context) : CodexViewModel(conte /// Each group aggregates one or more documentation sets. /// public IEnumerable Groups => - CodexNavigation?.GroupNavigations?.OrderBy(g => g.DisplayTitle) - ?? Enumerable.Empty(); + CodexNavigation?.GroupNavigations?.OrderBy(g => g.DisplayTitle) ?? Enumerable.Empty(); /// /// Documentation sets that are not part of any group, for rendering individual docset cards. /// public IEnumerable UngroupedDocumentationSets => - CodexNavigation?.DocumentationSetInfos? - .Where(ds => string.IsNullOrEmpty(ds.Group)) - .OrderBy(ds => ds.Title) - ?? Enumerable.Empty(); + CodexNavigation?.DocumentationSetInfos?.Where(ds => string.IsNullOrEmpty(ds.Group)).OrderBy(ds => ds.Title) ?? + Enumerable.Empty(); } diff --git a/src/Elastic.Codex/Navigation/CategoryNavigation.cs b/src/Elastic.Codex/Navigation/CategoryNavigation.cs index 345a535fa1..0cd1001418 100644 --- a/src/Elastic.Codex/Navigation/CategoryNavigation.cs +++ b/src/Elastic.Codex/Navigation/CategoryNavigation.cs @@ -56,7 +56,8 @@ IRootNavigationItem navigationRoot public ILeafNavigationItem Index { get; } = new CategoryIndexLeaf( new CategoryIndexPage(displayTitle), pathPrefix.TrimEnd('/'), - navigationRoot); + navigationRoot + ); /// public IReadOnlyCollection NavigationItems { get; private set; } = []; diff --git a/src/Elastic.Codex/Navigation/CodexNavigation.cs b/src/Elastic.Codex/Navigation/CodexNavigation.cs index c3b75fab4c..8fa256ccd2 100644 --- a/src/Elastic.Codex/Navigation/CodexNavigation.cs +++ b/src/Elastic.Codex/Navigation/CodexNavigation.cs @@ -26,7 +26,8 @@ public CodexNavigation( CodexConfiguration configuration, IReadOnlyList documentationSetReferences, ICodexDocumentationContext context, - IReadOnlyDictionary documentationSetNavigations) + IReadOnlyDictionary documentationSetNavigations + ) { Url = string.IsNullOrEmpty(configuration.SitePrefix) ? "" : configuration.SitePrefix; NavigationRoot = this; @@ -61,7 +62,8 @@ private sealed class NavigationBuilder( CodexNavigation codex, ICodexDocumentationContext context, IReadOnlyDictionary documentationSetNavigations, - CodexConfiguration configuration) + CodexConfiguration configuration + ) { private readonly List _items = []; private readonly List _docSetInfos = []; @@ -72,7 +74,8 @@ private sealed class NavigationBuilder( public sealed record BuildResult( IReadOnlyCollection NavigationItems, Dictionary Groups, - List DocumentationSetInfos); + List DocumentationSetInfos + ); public BuildResult Build(IReadOnlyList docSetRefs) { @@ -109,7 +112,8 @@ private void ProcessDocumentationSet(CodexDocumentationSetReference docSetRef) private CodexDocumentationSetInfo CreateDocumentationSetInfo( CodexDocumentationSetReference docSetRef, IRootNavigationItem rootNavItem, - string repoName) => + string repoName + ) => new() { Name = repoName, @@ -126,7 +130,8 @@ private void AttachToGroup( IDocumentationSetNavigation docSetNav, IRootNavigationItem rootNavItem, string pathPrefix, - CodexDocumentationSetInfo docSetInfo) + CodexDocumentationSetInfo docSetInfo + ) { var groupId = docSetRef.Group!; var groupNav = GetOrCreateGroup(groupId); @@ -173,7 +178,8 @@ private GroupNavigation GetOrCreateGroup(string groupId) private void AttachToCodexRoot( IDocumentationSetNavigation docSetNav, IRootNavigationItem rootNavItem, - string pathPrefix) + string pathPrefix + ) { if (docSetNav is INavigationHomeAccessor homeAccessor) homeAccessor.HomeProvider = new NavigationHomeProvider(pathPrefix, rootNavItem); @@ -191,11 +197,13 @@ private void FinalizeGroupDocumentationSetInfos() } private static string FormatGroupTitle(string slug) => - string.Join(" ", slug - .Replace('-', ' ') - .Replace('_', ' ') - .Split(' ', StringSplitOptions.RemoveEmptyEntries) - .Select(w => char.ToUpperInvariant(w[0]) + w[1..].ToLowerInvariant())); + string.Join( + " ", + slug.Replace('-', ' ') + .Replace('_', ' ') + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Select(w => char.ToUpperInvariant(w[0]) + w[1..].ToLowerInvariant()) + ); } /// @@ -251,14 +259,12 @@ void IAssignableChildrenNavigation.SetNavigationItems(IReadOnlyCollection public FrozenDictionary NavigationIndexedByOrder { get; } - private static int CountPages(INavigationItem item) => - item switch - { - INodeNavigationItem node => - 1 + node.NavigationItems.Sum(CountPages), - ILeafNavigationItem => 1, - _ => 0 - }; + private static int CountPages(INavigationItem item) => item switch + { + INodeNavigationItem node => 1 + node.NavigationItems.Sum(CountPages), + ILeafNavigationItem => 1, + _ => 0 + }; } /// diff --git a/src/Elastic.Codex/Navigation/GroupNavigation.cs b/src/Elastic.Codex/Navigation/GroupNavigation.cs index 0ebed70bfc..6f89dc9c3d 100644 --- a/src/Elastic.Codex/Navigation/GroupNavigation.cs +++ b/src/Elastic.Codex/Navigation/GroupNavigation.cs @@ -109,11 +109,7 @@ public record GroupIndexPage(string NavigationTitle) : IDocumentationFile /// Leaf navigation item for a group's index (landing) page. /// [DebuggerDisplay("{Url}")] -public class GroupIndexLeaf( - GroupIndexPage model, - string url, - GroupNavigation groupRoot -) : ILeafNavigationItem +public class GroupIndexLeaf(GroupIndexPage model, string url, GroupNavigation groupRoot) : ILeafNavigationItem { /// public IDocumentationFile Model { get; } = model; @@ -153,10 +149,7 @@ public record GroupLinkPage(string NavigationTitle, string Url) : IDocumentation /// Leaf in the codex nav that links to a group landing page (/g/slug). /// [DebuggerDisplay("{Url}")] -public class GroupLinkLeaf( - GroupLinkPage model, - CodexNavigation codexRoot -) : ILeafNavigationItem +public class GroupLinkLeaf(GroupLinkPage model, CodexNavigation codexRoot) : ILeafNavigationItem { /// public IDocumentationFile Model { get; } = model; diff --git a/src/Elastic.Codex/Sourcing/CodexCloneService.cs b/src/Elastic.Codex/Sourcing/CodexCloneService.cs index 1cf068fecf..94939eab2a 100644 --- a/src/Elastic.Codex/Sourcing/CodexCloneService.cs +++ b/src/Elastic.Codex/Sourcing/CodexCloneService.cs @@ -25,15 +25,23 @@ public class CodexCloneService(ILoggerFactory logFactory, ILinkIndexReader linkI // A registry mismatch on a known-path docset now triggers a recursive walk (see FindDocsetFile), so // this excludes common large generated/vendor directories in addition to .git and node_modules to // keep that walk cheap. - private static readonly string[] RecursiveSearchExcludedDirectories = [".git", "node_modules", "vendor", "dist", "build", "target", ".yarn"]; + private static readonly string[] RecursiveSearchExcludedDirectories = + [ + ".git", + "node_modules", + "vendor", + "dist", + "build", + "target", + ".yarn" + ]; private readonly ILogger _logger = logFactory.CreateLogger(); /// /// Discovers already-cloned repositories from disk without any git/network operations. /// Reads the link-index.snapshot.json written by the clone step and scans for initialized repos. /// - public static async Task DiscoverCheckouts( - CodexContext context, ILoggerFactory loggerFactory, Cancel ctx) + public static async Task DiscoverCheckouts(CodexContext context, ILoggerFactory loggerFactory, Cancel ctx) { var logger = loggerFactory.CreateLogger(); var checkoutDir = context.CheckoutDirectory; @@ -69,9 +77,7 @@ public class CodexCloneService(ILoggerFactory logFactory, ILinkIndexReader linkI var docSet = DocumentationSetFile.LoadMetadata(docsetFile); var docsDirectory = docsetFile.Directory!; var docsPath = Path.GetRelativePath(subDir.FullName, docsDirectory.FullName); - var docsPathForRef = string.IsNullOrEmpty(docsPath) || docsPath == "." - ? "." - : docsPath.Replace('\\', '/'); + var docsPathForRef = string.IsNullOrEmpty(docsPath) || docsPath == "." ? "." : docsPath.Replace('\\', '/'); WarnIfRegistryMismatch(context, repoName, docSet, docsPathForRef); string currentCommit; @@ -100,11 +106,7 @@ public class CodexCloneService(ILoggerFactory logFactory, ILinkIndexReader linkI /// /// Clones all repositories defined in the link index for the codex environment. /// - public async Task CloneAll( - CodexContext context, - bool fetchLatest, - bool assumeCloned, - Cancel ctx) + public async Task CloneAll(CodexContext context, bool fetchLatest, bool assumeCloned, Cancel ctx) { var checkouts = new List(); var checkoutDir = context.CheckoutDirectory; @@ -115,8 +117,7 @@ public async Task CloneAll( var linkRegistry = await linkIndexReader.GetRegistry(ctx); var repoEntries = GetRepositoryEntries(linkRegistry); - _logger.LogInformation("Cloning {Count} documentation sets to {Directory}", - repoEntries.Count, checkoutDir.FullName); + _logger.LogInformation("Cloning {Count} documentation sets to {Directory}", repoEntries.Count, checkoutDir.FullName); await Parallel.ForEachAsync( repoEntries, @@ -130,17 +131,15 @@ await Parallel.ForEachAsync( checkouts.Add(checkout); } await Task.CompletedTask; - }); + } + ); if (Path.IsPathRooted(LinkRegistrySnapshotFileName)) throw new InvalidOperationException($"Snapshot file name '{LinkRegistrySnapshotFileName}' must be a relative path."); var snapshotFilePath = Path.Join(context.CheckoutDirectory.FullName, LinkRegistrySnapshotFileName); - await context.WriteFileSystem.File.WriteAllTextAsync( - snapshotFilePath, - LinkRegistry.Serialize(linkRegistry), - ctx); + await context.WriteFileSystem.File.WriteAllTextAsync(snapshotFilePath, LinkRegistry.Serialize(linkRegistry), ctx); return new CodexCloneResult(checkouts, linkRegistry); } @@ -163,26 +162,27 @@ await context.WriteFileSystem.File.WriteAllTextAsync( return result; } - private CodexCheckout? CloneRepository(CodexContext context, (string RepoName, LinkRegistryEntry Entry) repoEntry, bool fetchLatest, bool assumeCloned) + private CodexCheckout? CloneRepository( + CodexContext context, + (string RepoName, LinkRegistryEntry Entry) repoEntry, + bool fetchLatest, + bool assumeCloned + ) { var (repoName, entry) = repoEntry; if (Path.IsPathRooted(repoName)) { - context.Collector.EmitError( - context.ConfigurationPath, - $"Repository name '{repoName}' must be a relative path"); + context.Collector.EmitError(context.ConfigurationPath, $"Repository name '{repoName}' must be a relative path"); return null; } - var repoDir = context.ReadFileSystem.DirectoryInfo.New( - Path.Join(context.CheckoutDirectory.FullName, repoName)); + var repoDir = context.ReadFileSystem.DirectoryInfo.New(Path.Join(context.CheckoutDirectory.FullName, repoName)); var gitUrl = GetGitUrl($"elastic/{repoName}"); var gitRef = fetchLatest ? entry.Branch : entry.GitReference; - _logger.LogInformation("Cloning {Name} from {Origin} at {GitRef}", - repoName, $"elastic/{repoName}", gitRef); + _logger.LogInformation("Cloning {Name} from {Origin} at {GitRef}", repoName, $"elastic/{repoName}", gitRef); try { @@ -194,8 +194,10 @@ await context.WriteFileSystem.File.WriteAllTextAsync( { // A failed prior clone leaves an initialized-but-empty .git dir. Treat this // identically to a clone failure: warn and skip rather than error. - context.Collector.EmitWarning(context.ConfigurationPath, - $"Could not clone repository '{repoName}' (HEAD unresolvable); skipping"); + context.Collector.EmitWarning( + context.ConfigurationPath, + $"Could not clone repository '{repoName}' (HEAD unresolvable); skipping" + ); return null; } _logger.LogInformation("Assuming {Name} is already cloned", repoName); @@ -224,8 +226,10 @@ await context.WriteFileSystem.File.WriteAllTextAsync( var docsetFile = FindDocsetFile(context.ReadFileSystem, repoDir, context.EnvironmentName); if (docsetFile == null) { - context.Collector.EmitWarning(context.ConfigurationPath, - $"docset.yml or _docset.yml not found in repository '{repoName}'; skipping"); + context.Collector.EmitWarning( + context.ConfigurationPath, + $"docset.yml or _docset.yml not found in repository '{repoName}'; skipping" + ); return null; } @@ -233,9 +237,7 @@ await context.WriteFileSystem.File.WriteAllTextAsync( var docsDirectory = docsetFile.Directory!; var docsPath = Path.GetRelativePath(repoDir.FullName, docsDirectory.FullName); - var docsPathForRef = string.IsNullOrEmpty(docsPath) || docsPath == "." - ? "." - : docsPath.Replace('\\', '/'); + var docsPathForRef = string.IsNullOrEmpty(docsPath) || docsPath == "." ? "." : docsPath.Replace('\\', '/'); WarnIfRegistryMismatch(context, repoName, docSet, docsPathForRef); var docSetRef = CreateDocumentationSetReference(repoName, entry, docsPathForRef, docSet); @@ -245,8 +247,7 @@ await context.WriteFileSystem.File.WriteAllTextAsync( { // Emit warning instead of error: repos may be in the link index before the clone // workflow has permission to access them. Continue with repos we can clone. - context.Collector.EmitWarning(context.ConfigurationPath, - $"Could not clone repository '{repoName}': {ex.Message}"); + context.Collector.EmitWarning(context.ConfigurationPath, $"Could not clone repository '{repoName}': {ex.Message}"); _logger.LogWarning(ex, "Could not clone repository {Name}; skipping", repoName); return null; } @@ -291,7 +292,12 @@ bool MatchesEnvironment(IFileInfo file) return recursiveMatch ?? firstKnownPathFile ?? firstRecursiveHit; } - private static IFileInfo? SearchForDocsetRecursive(IFileSystem fileSystem, IDirectoryInfo directory, Func matchesEnvironment, ref IFileInfo? firstHit) + private static IFileInfo? SearchForDocsetRecursive( + IFileSystem fileSystem, + IDirectoryInfo directory, + Func matchesEnvironment, + ref IFileInfo? firstHit + ) { try { @@ -336,16 +342,20 @@ private static void WarnIfRegistryMismatch(CodexContext context, string repoName return; var registryDescription = string.IsNullOrEmpty(docSet.Registry) ? "no registry" : $"registry: {docSet.Registry}"; - context.Collector.EmitWarning(context.ConfigurationPath, + context.Collector.EmitWarning( + context.ConfigurationPath, $"Repository '{repoName}' docset '{docsPath}' declares {registryDescription}, not registry: {context.EnvironmentName}; " + - "using it via fallback discovery. Set 'registry' in its docset.yml to opt in explicitly."); + "using it via fallback discovery. Set 'registry' in its docset.yml to opt in explicitly." + ); } internal static CodexDocumentationSetReference CreateDocumentationSetReference( string repoName, LinkRegistryEntry entry, string docsPath, - DocumentationSetFile docSet) => new() + DocumentationSetFile docSet + ) => + new() { Name = repoName, Origin = $"elastic/{repoName}", @@ -357,16 +367,16 @@ internal static CodexDocumentationSetReference CreateDocumentationSetReference( private static string GetGitUrl(string origin) { - if (origin.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || - origin.StartsWith("git@", StringComparison.OrdinalIgnoreCase)) + if ( + origin.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || + origin.StartsWith("git@", StringComparison.OrdinalIgnoreCase) + ) return origin; if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"))) { var token = Environment.GetEnvironmentVariable("GITHUB_TOKEN"); - return !string.IsNullOrEmpty(token) - ? $"https://oauth2:{token}@github.com/{origin}.git" - : $"https://github.com/{origin}.git"; + return !string.IsNullOrEmpty(token) ? $"https://oauth2:{token}@github.com/{origin}.git" : $"https://github.com/{origin}.git"; } return $"git@github.com:{origin}.git"; @@ -381,8 +391,7 @@ public record CodexCloneResult(IReadOnlyList Checkouts, LinkRegis /// /// Gets the documentation set references for the cloned checkouts. /// - public IReadOnlyList DocumentationSetReferences => - Checkouts.Select(c => c.Reference).ToList(); + public IReadOnlyList DocumentationSetReferences => Checkouts.Select(c => c.Reference).ToList(); } /// @@ -393,4 +402,5 @@ public record CodexCheckout( IDirectoryInfo RepositoryDirectory, IDirectoryInfo DocsDirectory, IFileInfo DocsetFile, - string CommitHash); + string CommitHash +); diff --git a/src/Elastic.Codex/Sourcing/CodexGitRepository.cs b/src/Elastic.Codex/Sourcing/CodexGitRepository.cs index d188dfd28c..67d2ca0de1 100644 --- a/src/Elastic.Codex/Sourcing/CodexGitRepository.cs +++ b/src/Elastic.Codex/Sourcing/CodexGitRepository.cs @@ -12,8 +12,15 @@ namespace Elastic.Codex.Sourcing; /// /// Git repository operations optimized for shallow clones. /// -public class CodexGitRepository(ILoggerFactory logFactory, IDiagnosticsCollector collector, IDirectoryInfo workingDirectory) - : ExternalCommandExecutor(collector, workingDirectory, Environment.GetEnvironmentVariable("CI") is null or "" ? null : TimeSpan.FromMinutes(10)) +public class CodexGitRepository( + ILoggerFactory logFactory, + IDiagnosticsCollector collector, + IDirectoryInfo workingDirectory +) : ExternalCommandExecutor( + collector, + workingDirectory, + Environment.GetEnvironmentVariable("CI") is null or "" ? null : TimeSpan.FromMinutes(10) +) { /// protected override ILogger Logger { get; } = logFactory.CreateLogger(); @@ -21,7 +28,10 @@ public class CodexGitRepository(ILoggerFactory logFactory, IDiagnosticsCollector private static readonly Dictionary EnvironmentVars = new() { // Disable git editor prompts - { "GIT_EDITOR", "true" } + { + "GIT_EDITOR", + "true" + } }; public string GetCurrentCommit() => Capture("git", "rev-parse", "HEAD"); @@ -38,9 +48,7 @@ public void Fetch(string reference) => public void EnableSparseCheckout(string[] folders) => ExecIn(EnvironmentVars, "git", ["sparse-checkout", "set", "--no-cone", .. folders]); - public void Checkout(string reference) => - ExecIn(EnvironmentVars, "git", "checkout", "--force", reference); + public void Checkout(string reference) => ExecIn(EnvironmentVars, "git", "checkout", "--force", reference); - public void GitAddOrigin(string origin) => - ExecIn(EnvironmentVars, "git", "remote", "add", "origin", origin); + public void GitAddOrigin(string origin) => ExecIn(EnvironmentVars, "git", "remote", "add", "origin", origin); } diff --git a/src/Elastic.Documentation.Configuration/Assembler/AssemblyConfiguration.cs b/src/Elastic.Documentation.Configuration/Assembler/AssemblyConfiguration.cs index 56154caa1f..29a7dfe9bc 100644 --- a/src/Elastic.Documentation.Configuration/Assembler/AssemblyConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Assembler/AssemblyConfiguration.cs @@ -31,20 +31,16 @@ public static AssemblyConfiguration Deserialize(string yaml, bool skipPrivateRep // If we are skipping private repositories, and we can locate the solution directory. include the local docs-content repository // this allows us to test new docset features as part of the assembler build - if (skipPrivateRepositories + if ( + skipPrivateRepositories && config.ReferenceRepositories.TryGetValue("docs-builder", out var docsContentRepository) && Paths.GetSolutionDirectory() is { } solutionDir - ) + ) { var docsRepositoryPath = Path.Join(solutionDir.FullName, "docs"); - config.ReferenceRepositories["docs-builder"] = docsContentRepository with - { - Skip = false, - Path = docsRepositoryPath - }; + config.ReferenceRepositories["docs-builder"] = docsContentRepository with { Skip = false, Path = docsRepositoryPath }; } - var privateRepositories = config.ReferenceRepositories.Where(r => r.Value.Private).ToList(); foreach (var (name, _) in privateRepositories) { @@ -56,13 +52,10 @@ public static AssemblyConfiguration Deserialize(string yaml, bool skipPrivateRep env.Name = name; config.Narrative = RepositoryDefaults(config.Narrative, NarrativeRepository.RepositoryName); - config.AvailableRepositories = config.ReferenceRepositories.Values - .Where(r => !r.Skip) - .Concat([config.Narrative]).ToDictionary(kvp => kvp.Name, kvp => kvp); + config.AvailableRepositories = + config.ReferenceRepositories.Values.Where(r => !r.Skip).Concat([config.Narrative]).ToDictionary(kvp => kvp.Name, kvp => kvp); - config.PrivateRepositories = privateRepositories - .Where(r => !r.Value.Skip) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + config.PrivateRepositories = privateRepositories.Where(r => !r.Value.Skip).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); return config; } catch (Exception e) @@ -73,8 +66,7 @@ public static AssemblyConfiguration Deserialize(string yaml, bool skipPrivateRep } } - private static TRepository RepositoryDefaults(TRepository r, string name) - where TRepository : Repository, new() + private static TRepository RepositoryDefaults(TRepository r, string name) where TRepository : Repository, new() { // ReSharper disable NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract var repository = r ?? new TRepository(); @@ -89,10 +81,7 @@ private static TRepository RepositoryDefaults(TRepository r, string // ensure we always null path if we are running in CI if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("CI"))) { - repository = repository with - { - Path = null - }; + repository = repository with { Path = null }; } if (string.IsNullOrEmpty(repository.Origin)) @@ -134,7 +123,13 @@ private static TRepository RepositoryDefaults(TRepository r, string /// Returns whether the is configured as an integration branch or tag for the given /// . - public ContentSourceMatch Match(ILoggerFactory logFactory, string repository, string branchOrTag, Product? product, bool alreadyPublishing) + public ContentSourceMatch Match( + ILoggerFactory logFactory, + string repository, + string branchOrTag, + Product? product, + bool alreadyPublishing + ) { var logger = logFactory.CreateLogger(); var match = new ContentSourceMatch(null, null, null, false); @@ -157,10 +152,7 @@ public ContentSourceMatch Match(ILoggerFactory logFactory, string repository, st if (isVersionBranch || branchOrTag == "main" || branchOrTag == "master") { logger.LogInformation("Speculatively building {Repository} since it looks like an integration branch", repository); - return match with - { - Speculative = true - }; + return match with { Speculative = true }; } logger.LogInformation("{Repository} on '{Branch}' does not look like it needs a speculative build", repository, branchOrTag); return match; @@ -170,33 +162,30 @@ public ContentSourceMatch Match(ILoggerFactory logFactory, string repository, st var next = r.GetBranch(ContentSource.Next); var edge = r.GetBranch(ContentSource.Edge); var isCdWorkflow = current == next && next == edge; - logger.LogInformation("Active content-sources for {Repository}. current: {Current}, next: {Next}, edge: {Edge} (branching strategy: {Strategy})", - repository, current, next, edge, isCdWorkflow ? "cd/continuous-deployment" : "tagged-release"); + logger.LogInformation( + "Active content-sources for {Repository}. current: {Current}, next: {Next}, edge: {Edge} (branching strategy: {Strategy})", + repository, + current, + next, + edge, + isCdWorkflow ? "cd/continuous-deployment" : "tagged-release" + ); if (current == branchOrTag) { logger.LogInformation("Content-Source current: {Current} matches: {Branch}", current, branchOrTag); - match = match with - { - Current = ContentSource.Current - }; + match = match with { Current = ContentSource.Current }; } if (next == branchOrTag) { logger.LogInformation("Content-Source next: {Next} matches: {Branch}", next, branchOrTag); - match = match with - { - Next = ContentSource.Next - }; + match = match with { Next = ContentSource.Next }; } if (edge == branchOrTag) { logger.LogInformation("Content-Source edge: {Edge} matches: {Branch}", edge, branchOrTag); - match = match with - { - Edge = ContentSource.Edge - }; + match = match with { Edge = ContentSource.Edge }; } // check version branches @@ -211,18 +200,17 @@ public ContentSourceMatch Match(ILoggerFactory logFactory, string repository, st if (v >= currentVersion) { logger.LogInformation("Speculative build because {Branch} is gte current {Current}", branchOrTag, currentVersion); - match = match with - { - Speculative = true - }; + match = match with { Speculative = true }; } else if (v == previousCurrentVersion) { - logger.LogInformation("Speculative build {Branch} is the previous minor '{ProductPreviousMinor}' of current {Current}", branchOrTag, previousCurrentVersion, currentVersion); - match = match with - { - Speculative = true - }; + logger.LogInformation( + "Speculative build {Branch} is the previous minor '{ProductPreviousMinor}' of current {Current}", + branchOrTag, + previousCurrentVersion, + currentVersion + ); + match = match with { Speculative = true }; } else logger.LogInformation("NO speculative build because {Branch} is lt {Current}", branchOrTag, currentVersion); @@ -232,35 +220,45 @@ public ContentSourceMatch Match(ILoggerFactory logFactory, string repository, st { if (!alreadyPublishing) { - logger.LogInformation("Current is not using versioned branches and is not publishing to the registry yet, using product information to determine speculative build is needed"); + logger.LogInformation( + "Current is not using versioned branches and is not publishing to the registry yet, using product information to determine speculative build is needed" + ); var productVersion = versioningSystem.Current; var anchoredProductVersion = new SemVersion(productVersion.Major, productVersion.Minor, 0); if (v > anchoredProductVersion) { - logger.LogInformation("Speculative build {Branch} is gt product current '{ProductCurrent}' anchored at {ProductAnchored}", branchOrTag, - productVersion, anchoredProductVersion); - match = match with - { - Speculative = true - }; + logger.LogInformation( + "Speculative build {Branch} is gt product current '{ProductCurrent}' anchored at {ProductAnchored}", + branchOrTag, + productVersion, + anchoredProductVersion + ); + match = match with { Speculative = true }; } else - logger.LogInformation("NO speculative build {Branch} is lte product current '{ProductCurrent}'", branchOrTag, productVersion); + logger.LogInformation( + "NO speculative build {Branch} is lte product current '{ProductCurrent}'", + branchOrTag, + productVersion + ); } else - logger.LogInformation("NO speculative build, repository is not using version branches to publish to documentation and is already in the link registry"); + logger.LogInformation( + "NO speculative build, repository is not using version branches to publish to documentation and is already in the link registry" + ); } else - logger.LogInformation("No versioning system found for {Repository} on {Branch}, can not determine speculative build until repository is specified in docs-builder configuration", repository, branchOrTag); + logger.LogInformation( + "No versioning system found for {Repository} on {Branch}, can not determine speculative build until repository is specified in docs-builder configuration", + repository, + branchOrTag + ); } // if we haven't matched anything yet, and the branch is 'main' or 'master' always build if (match is { Current: null, Next: null, Edge: null, Speculative: false } && branchOrTag is "main" or "master") { - return match with - { - Speculative = true - }; + return match with { Speculative = true }; } return match; diff --git a/src/Elastic.Documentation.Configuration/Assembler/Repository.cs b/src/Elastic.Documentation.Configuration/Assembler/Repository.cs index c09a719766..c8932036e9 100644 --- a/src/Elastic.Documentation.Configuration/Assembler/Repository.cs +++ b/src/Elastic.Documentation.Configuration/Assembler/Repository.cs @@ -61,5 +61,4 @@ public record Repository ContentSource.Edge => GitReferenceEdge, _ => throw new ArgumentException($"The content source {contentSource} is not supported.", nameof(contentSource)) }; - } diff --git a/src/Elastic.Documentation.Configuration/BuildContext.cs b/src/Elastic.Documentation.Configuration/BuildContext.cs index 99835cdd01..ab27f84c97 100644 --- a/src/Elastic.Documentation.Configuration/BuildContext.cs +++ b/src/Elastic.Documentation.Configuration/BuildContext.cs @@ -20,7 +20,8 @@ namespace Elastic.Documentation.Configuration; public record BuildContext : IDocumentationSetContext, IDocumentationConfigurationContext { - public static string Version { get; } = Assembly.GetExecutingAssembly().GetCustomAttributes() + public static string Version { get; } = Assembly.GetExecutingAssembly() + .GetCustomAttributes() .FirstOrDefault()?.InformationalVersion ?? "0.0.0"; /// The resolved documentation filesystem. All other path/scope properties are computed from this. @@ -61,11 +62,7 @@ public record BuildContext : IDocumentationSetContext, IDocumentationConfigurati public OptimizelyConfiguration Optimizely { get; init; } public Uri? CanonicalBaseUrl { get; init; } - public string? UrlPathPrefix - { - get => string.IsNullOrWhiteSpace(field) ? "" : $"/{field.Trim('/')}"; - init; - } + public string? UrlPathPrefix { get => string.IsNullOrWhiteSpace(field) ? "" : $"/{field.Trim('/')}"; init; } /// Site root path for HTMX (e.g. codex root). When set, overrides derivation from UrlPathPrefix. public string? SiteRootPath { get; init; } @@ -95,7 +92,6 @@ public BuildContext( GoogleTagManager = new GoogleTagManagerConfiguration { Enabled = false }; Optimizely = new OptimizelyConfiguration { Enabled = false }; - ConfigurationYaml = ConfigurationPath.Exists ? DocumentationSetFile.LoadAndResolve(collector, ConfigurationPath, fileSystem.Read) : new DocumentationSetFile(); diff --git a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs index dd9b9a9e1d..233850518b 100644 --- a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs +++ b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs @@ -114,7 +114,12 @@ public bool IsExcluded(string relativePath) return Exclude.Any(g => g.IsMatch(relativePath)); } - public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetContext context, VersionsConfiguration versionsConfig, ProductsConfiguration productsConfig) + public ConfigurationFile( + DocumentationSetFile docSetFile, + IDocumentationSetContext context, + VersionsConfiguration versionsConfig, + ProductsConfiguration productsConfig + ) { _context = context; ScopeDirectory = context.ConfigurationPath.Directory!; @@ -125,7 +130,6 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte return; } - var redirectFile = new RedirectFile(_context); Redirects = redirectFile.Redirects; @@ -143,19 +147,22 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte // Parse registry (null/empty/"public" -> Public) var registry = DocSetRegistry.Public; - if (!string.IsNullOrWhiteSpace(docSetFile.Registry) && - DocSetRegistryExtensions.TryParse(docSetFile.Registry.Trim(), out var parsedRegistry, true)) + if ( + !string.IsNullOrWhiteSpace(docSetFile.Registry) && + DocSetRegistryExtensions.TryParse(docSetFile.Registry.Trim(), out var parsedRegistry, true) + ) registry = parsedRegistry; Registry = registry; // Parse cross-link entries with optional registry prefix (e.g. public://elasticsearch) - CrossLinkEntries = docSetFile.CrossLinks - .Where(raw => !string.IsNullOrWhiteSpace(raw)) - .Select(raw => ParseCrossLinkEntry(raw.Trim(), registry, context.ConfigurationPath, context)) - .Where(entry => entry is not null) - .Select(entry => entry!) - .ToArray(); + CrossLinkEntries = + docSetFile.CrossLinks + .Where(raw => !string.IsNullOrWhiteSpace(raw)) + .Select(raw => ParseCrossLinkEntry(raw.Trim(), registry, context.ConfigurationPath, context)) + .Where(entry => entry is not null) + .Select(entry => entry!) + .ToArray(); CrossLinkRepositories = CrossLinkEntries.Select(e => e.Repository).ToArray(); @@ -190,10 +197,11 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte var interpolated = EnvironmentInterpolation.Interpolate( docSetFile.Storybook.Registry?.Trim(), context.Environment, - name => context.EmitWarning( - context.ConfigurationPath, - $"'storybook.registry' references environment variable '{name}' which is not allow-listed for interpolation and is left literal. Allowed: {string.Join(", ", EnvironmentInterpolation.AllowedVariables)}." - ) + name => + context.EmitWarning( + context.ConfigurationPath, + $"'storybook.registry' references environment variable '{name}' which is not allow-listed for interpolation and is left literal. Allowed: {string.Join(", ", EnvironmentInterpolation.AllowedVariables)}." + ) ); StorybookRegistry = interpolated.Value; StorybookRegistryFallback = interpolated.Fallback; @@ -202,10 +210,11 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte // Process products from docset - resolve ProductLinks to Product objects if (docSetFile.Products.Count > 0) { - Products = docSetFile.Products - .Select(link => productsConfig.Products.GetValueOrDefault(link.Id.Replace('_', '-'))) - .Where(product => product is not null) - .ToHashSet()!; + Products = + docSetFile.Products + .Select(link => productsConfig.Products.GetValueOrDefault(link.Id.Replace('_', '-'))) + .Where(product => product is not null) + .ToHashSet()!; } // Process branding with validation @@ -230,7 +239,10 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte // primary-nav requires the Elastic global navigation which is not available for white-label builds if (Branding is not null && docSetFile.Features.PrimaryNav is true) - context.EmitError(context.ConfigurationPath, "'features.primary-nav' cannot be used together with 'branding': the primary nav requires Elastic global navigation."); + context.EmitError( + context.ConfigurationPath, + "'features.primary-nav' cannot be used together with 'branding': the primary nav requires Elastic global navigation." + ); // Add version substitutions foreach (var (id, system) in versionsConfig.VersioningSystems) @@ -297,28 +309,28 @@ private static string UnknownCtaWarning(string ctaName, IEnumerable know return null; } var url = definition.Button.Url.Trim(); - if (Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri) && uri.IsAbsoluteUri && - uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) + if ( + Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri) + && uri.IsAbsoluteUri + && uri.Scheme != Uri.UriSchemeHttp + && uri.Scheme != Uri.UriSchemeHttps + ) { context.EmitError(context.ConfigurationPath, $"'cta.{name}.button.url' must use http/https or a relative URL."); return null; } if (definition.Benefits.Count > Cta.MaxBenefits) { - context.EmitError(context.ConfigurationPath, $"'cta.{name}.benefits' has {definition.Benefits.Count} entries; a maximum of {Cta.MaxBenefits} is allowed."); + context.EmitError( + context.ConfigurationPath, + $"'cta.{name}.benefits' has {definition.Benefits.Count} entries; a maximum of {Cta.MaxBenefits} is allowed." + ); return null; } - return new Cta - { - Name = name, - Label = definition.Button.Label, - Url = url, - Benefits = definition.Benefits - }; + return new Cta { Name = name, Label = definition.Button.Label, Url = url, Benefits = definition.Benefits }; } - private static readonly HashSet AllowedImageExtensions = - [".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"]; + private static readonly HashSet AllowedImageExtensions = [".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"]; private static BrandingConfiguration ValidateBranding(BrandingConfiguration branding, IDocumentationSetContext context) { @@ -337,8 +349,7 @@ private static BrandingConfiguration ValidateBranding(BrandingConfiguration bran { foreach (var name in candidates) { - var f = context.ReadFileSystem.FileInfo.New( - Path.Join(context.DocumentationSourceDirectory.FullName, name)); + var f = context.ReadFileSystem.FileInfo.New(Path.Join(context.DocumentationSourceDirectory.FullName, name)); if (f.Exists && f.LinkTarget is null) return name; } @@ -353,27 +364,27 @@ private static BrandingConfiguration ValidateBranding(BrandingConfiguration bran var ext = Path.GetExtension(imagePath).ToLowerInvariant(); if (!AllowedImageExtensions.Contains(ext)) { - context.EmitError(context.ConfigurationPath, - $"'{fieldName}' has unsupported extension '{ext}'. Allowed: {string.Join(", ", AllowedImageExtensions)}"); + context.EmitError( + context.ConfigurationPath, + $"'{fieldName}' has unsupported extension '{ext}'. Allowed: {string.Join(", ", AllowedImageExtensions)}" + ); return null; } - var resolved = context.ReadFileSystem.FileInfo.New( - Path.GetFullPath(Path.Join(context.DocumentationSourceDirectory.FullName, imagePath)) - ); + var resolved = context.ReadFileSystem + .FileInfo + .New(Path.GetFullPath(Path.Join(context.DocumentationSourceDirectory.FullName, imagePath))); if (!resolved.IsSubPathOf(context.DocumentationSourceDirectory)) { - context.EmitError(context.ConfigurationPath, - $"'{fieldName}' path '{imagePath}' escapes the documentation source directory."); + context.EmitError(context.ConfigurationPath, $"'{fieldName}' path '{imagePath}' escapes the documentation source directory."); return null; } var symlinkError = ValidateFileAccess(resolved, context.DocumentationSourceDirectory); if (symlinkError is not null) { - context.EmitError(context.ConfigurationPath, - $"'{fieldName}' path '{imagePath}' is unsafe: {symlinkError}"); + context.EmitError(context.ConfigurationPath, $"'{fieldName}' path '{imagePath}' is unsafe: {symlinkError}"); return null; } @@ -389,7 +400,8 @@ private static BrandingConfiguration ValidateBranding(BrandingConfiguration bran private static string[] ParseReleaseNotesProducts( IReadOnlyList references, ProductsConfiguration productsConfig, - IDocumentationSetContext context) + IDocumentationSetContext context + ) { if (references.Count == 0) return []; @@ -406,8 +418,10 @@ private static string[] ParseReleaseNotesProducts( if (!IsValidProductId(product)) { - context.EmitError(context.ConfigurationPath, - $"Invalid 'release_notes' product '{product}'. Product ids must match [a-zA-Z0-9_-]+."); + context.EmitError( + context.ConfigurationPath, + $"Invalid 'release_notes' product '{product}'. Product ids must match [a-zA-Z0-9_-]+." + ); continue; } @@ -415,15 +429,19 @@ private static string[] ParseReleaseNotesProducts( var normalized = product.Replace('_', '-'); if (!productsConfig.Products.TryGetValue(normalized, out var resolved)) { - context.EmitError(context.ConfigurationPath, - $"Unknown 'release_notes' product '{product}'. It must be a product id defined in products.yml."); + context.EmitError( + context.ConfigurationPath, + $"Unknown 'release_notes' product '{product}'. It must be a product id defined in products.yml." + ); continue; } if (!resolved.Features.ReleaseNotes) { - context.EmitError(context.ConfigurationPath, - $"Product '{product}' declared in 'release_notes' does not participate in the release-notes system (it lacks the 'release-notes' feature in products.yml)."); + context.EmitError( + context.ConfigurationPath, + $"Product '{product}' declared in 'release_notes' does not participate in the release-notes system (it lacks the 'release-notes' feature in products.yml)." + ); continue; } @@ -441,12 +459,15 @@ private static bool IsValidProductId(string product) => string productKey, ApiProductSequence apiSequence, ProductsConfiguration productsConfig, - IDocumentationSetContext context) + IDocumentationSetContext context + ) { if (apiSequence.SingleEntry is not { } entry) { - context.EmitError(context.ConfigurationPath, - $"API configuration for '{productKey}' must have exactly one entry, found {apiSequence.Entries.Count}."); + context.EmitError( + context.ConfigurationPath, + $"API configuration for '{productKey}' must have exactly one entry, found {apiSequence.Entries.Count}." + ); return null; } @@ -473,7 +494,8 @@ private static bool IsValidProductId(string product) => File = context.ConfigurationPath.FullName, Line = entry.ProductLine, Column = entry.ProductColumn, - Message = $"Unknown 'product: {entry.Product}' for API '{productKey}'. It must be a product id defined in products.yml.{(string.IsNullOrEmpty(hint) ? "" : $" {hint}")}" + Message = + $"Unknown 'product: {entry.Product}' for API '{productKey}'. It must be a product id defined in products.yml.{(string.IsNullOrEmpty(hint) ? "" : $" {hint}")}" }); return null; } @@ -486,8 +508,9 @@ private static bool IsValidProductId(string product) => File = context.ConfigurationPath.FullName, Line = entry.Line, Column = entry.Column, - Message = $"API '{productKey}' is missing required 'spec:'. Its basename is required to resolve " + - "the remote version index, even when the file is not present locally." + Message = + $"API '{productKey}' is missing required 'spec:'. Its basename is required to resolve " + + "the remote version index, even when the file is not present locally." }); return null; } @@ -556,7 +579,8 @@ private static bool IsValidProductId(string product) => File = context.ConfigurationPath.FullName, Line = entry.RepositoryLine ?? entry.Line, Column = entry.RepositoryColumn ?? entry.Column, - Message = $"'repository: {entry.Repository}' for API '{productKey}' must be in 'org/repo' form, e.g. 'elastic/elasticsearch-specification'." + Message = + $"'repository: {entry.Repository}' for API '{productKey}' must be in 'org/repo' form, e.g. 'elastic/elasticsearch-specification'." }); return null; } @@ -583,8 +607,9 @@ private static List ResolveApiChildren(string productKey, List(); foreach (var child in children) @@ -600,23 +625,26 @@ private static List ResolveApiChildren(string productKey, List ResolveApiChildren(string productKey, List ResolveApiChildren(string productKey, List extensions) { - private readonly HashSet _extensionsSet = [ - ..extensions - ]; + private readonly HashSet _extensionsSet = [.. extensions]; private bool IsEnabled(string key) => _extensionsSet.Contains(key); diff --git a/src/Elastic.Documentation.Configuration/Builder/FeatureFlags.cs b/src/Elastic.Documentation.Configuration/Builder/FeatureFlags.cs index a8154285c3..de9d4c8db4 100644 --- a/src/Elastic.Documentation.Configuration/Builder/FeatureFlags.cs +++ b/src/Elastic.Documentation.Configuration/Builder/FeatureFlags.cs @@ -14,11 +14,7 @@ public void Set(string key, bool value) _featureFlags[normalizedKey] = value; } - public bool PrimaryNavEnabled - { - get => IsEnabled("primary-nav"); - set => _featureFlags["primary-nav"] = value; - } + public bool PrimaryNavEnabled { get => IsEnabled("primary-nav"); set => _featureFlags["primary-nav"] = value; } public bool DisableGitHubEditLink { @@ -26,31 +22,15 @@ public bool DisableGitHubEditLink set => _featureFlags["disable-github-edit-link"] = value; } - public bool StagingElasticNavEnabled - { - get => IsEnabled("staging-elastic-nav"); - set => _featureFlags["staging-elastic-nav"] = value; - } + public bool StagingElasticNavEnabled { get => IsEnabled("staging-elastic-nav"); set => _featureFlags["staging-elastic-nav"] = value; } - public bool WebsiteSearchEnabled - { - get => IsEnabled("website-search"); - set => _featureFlags["website-search"] = value; - } + public bool WebsiteSearchEnabled { get => IsEnabled("website-search"); set => _featureFlags["website-search"] = value; } public string? WebsiteSearchScriptUrl { get; set; } - public bool AirGappedEnabled - { - get => IsEnabled("air-gapped"); - set => _featureFlags["air-gapped"] = value; - } + public bool AirGappedEnabled { get => IsEnabled("air-gapped"); set => _featureFlags["air-gapped"] = value; } - public bool DiagnosticsPanelEnabled - { - get => IsEnabled("diagnostics-panel"); - set => _featureFlags["diagnostics-panel"] = value; - } + public bool DiagnosticsPanelEnabled { get => IsEnabled("diagnostics-panel"); set => _featureFlags["diagnostics-panel"] = value; } public bool AssemblerApiExplorerEnabled { @@ -58,17 +38,9 @@ public bool AssemblerApiExplorerEnabled set => _featureFlags["assembler-api-explorer"] = value; } - public bool GuideNavEnabled - { - get => IsEnabled("guide-nav"); - set => _featureFlags["guide-nav"] = value; - } + public bool GuideNavEnabled { get => IsEnabled("guide-nav"); set => _featureFlags["guide-nav"] = value; } - public bool NavigationPreviewEnabled - { - get => IsEnabled("navigation-preview"); - set => _featureFlags["navigation-preview"] = value; - } + public bool NavigationPreviewEnabled { get => IsEnabled("navigation-preview"); set => _featureFlags["navigation-preview"] = value; } private bool IsEnabled(string key) { diff --git a/src/Elastic.Documentation.Configuration/Builder/RedirectFile.cs b/src/Elastic.Documentation.Configuration/Builder/RedirectFile.cs index ebb6a12597..0b346396b9 100644 --- a/src/Elastic.Documentation.Configuration/Builder/RedirectFile.cs +++ b/src/Elastic.Documentation.Configuration/Builder/RedirectFile.cs @@ -19,7 +19,9 @@ public RedirectFile(IDocumentationSetContext context, IFileInfo? source = null) { var docsetConfigurationPath = context.ConfigurationPath; var redirectFileName = docsetConfigurationPath.Name.StartsWith('_') ? "_redirects.yml" : "redirects.yml"; - var redirectFileInfo = docsetConfigurationPath.FileSystem.FileInfo.New(Path.Join(docsetConfigurationPath.Directory!.FullName, redirectFileName)); + var redirectFileInfo = docsetConfigurationPath.FileSystem + .FileInfo + .New(Path.Join(docsetConfigurationPath.Directory!.FullName, redirectFileName)); Source = source ?? redirectFileInfo; Context = context; @@ -67,7 +69,8 @@ public RedirectFile(IDocumentationSetContext context, IFileInfo? source = null) if (entryValue.Value is YamlScalarNode) { var to = reader.ReadString(entryValue); - dictionary.Add(key, + dictionary.Add( + key, !string.IsNullOrEmpty(to) ? to.StartsWith('!') ? new LinkRedirect { To = to.TrimStart('!'), Anchors = LinkRedirect.CatchAllAnchors } @@ -119,8 +122,7 @@ public RedirectFile(IDocumentationSetContext context, IFileInfo? source = null) if (redirect.To is null && redirect.Many is null or { Length: 0 }) return redirect with { To = file }; - return string.IsNullOrEmpty(redirect.To) && redirect.Many is null or { Length: 0 } - ? null : redirect; + return string.IsNullOrEmpty(redirect.To) && redirect.Many is null or { Length: 0 } ? null : redirect; } private static LinkSingleRedirect[]? ReadManyRedirects(YamlStreamReader reader, string file, YamlNode node) @@ -160,10 +162,6 @@ public RedirectFile(IDocumentationSetContext context, IFileInfo? source = null) if (redirects.Count == 0) return null; - return - [ - ..redirects - .Where(r => r.To is not null && r.Anchors is not null && r.Anchors.Count >= 0) - ]; + return [.. redirects.Where(r => r.To is not null && r.Anchors is not null && r.Anchors.Count >= 0)]; } } diff --git a/src/Elastic.Documentation.Configuration/Changelog/BlockConfiguration.cs b/src/Elastic.Documentation.Configuration/Changelog/BlockConfiguration.cs index c64aac2a97..94db7312d4 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/BlockConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/BlockConfiguration.cs @@ -214,9 +214,7 @@ public static BundleFilterMode DetermineFilterMode(this BundleRules bundleRules) if (bundleRules.ByProduct is { Count: > 0 }) return BundleFilterMode.PerProductContext; - if ((bundleRules.ExcludeProducts?.Count ?? 0) > 0 || - (bundleRules.IncludeProducts?.Count ?? 0) > 0 || - bundleRules.Blocker != null) + if ((bundleRules.ExcludeProducts?.Count ?? 0) > 0 || (bundleRules.IncludeProducts?.Count ?? 0) > 0 || bundleRules.Blocker != null) return BundleFilterMode.GlobalContent; return BundleFilterMode.NoFiltering; diff --git a/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfiguration.cs b/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfiguration.cs index f6bd64127d..e0ec18c5c9 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfiguration.cs @@ -15,18 +15,16 @@ public record ChangelogConfiguration /// /// Default types for changelog entries (derived from ChangelogEntryType enum) /// - public static IReadOnlyList DefaultTypes { get; } = - ChangelogEntryTypeExtensions.GetValues() - .Select(t => t.ToStringFast(true)) - .ToList(); + public static IReadOnlyList DefaultTypes { get; } = ChangelogEntryTypeExtensions.GetValues() + .Select(t => t.ToStringFast(true)) + .ToList(); /// /// Default subtypes for breaking changes (derived from ChangelogEntrySubtype enum) /// - public static IReadOnlyList DefaultSubtypes { get; } = - ChangelogEntrySubtypeExtensions.GetValues() - .Select(s => s.ToStringFast(true)) - .ToList(); + public static IReadOnlyList DefaultSubtypes { get; } = ChangelogEntrySubtypeExtensions.GetValues() + .Select(s => s.ToStringFast(true)) + .ToList(); /// /// Required types that must be present in the configuration. diff --git a/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationLoader.cs b/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationLoader.cs index 733fc51586..1b9499b799 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationLoader.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationLoader.cs @@ -23,12 +23,11 @@ public class ChangelogConfigurationLoader(ILoggerFactory logFactory, IConfigurat { private readonly ILogger _logger = logFactory.CreateLogger(); - private static readonly IDeserializer ConfigurationDeserializer = - new StaticDeserializerBuilder(new YamlStaticContext()) - .WithNamingConvention(UnderscoredNamingConvention.Instance) - .WithTypeConverter(new YamlLenientListConverter()) - .WithTypeConverter(new TypeEntryYamlConverter()) - .Build(); + private static readonly IDeserializer ConfigurationDeserializer = new StaticDeserializerBuilder(new YamlStaticContext()) + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .WithTypeConverter(new YamlLenientListConverter()) + .WithTypeConverter(new TypeEntryYamlConverter()) + .Build(); /// /// Deserializes changelog configuration YAML content. @@ -98,7 +97,11 @@ internal static ChangelogConfigurationYaml DeserializeConfiguration(string yaml) } } - private ChangelogConfiguration? ParseConfiguration(IDiagnosticsCollector collector, ChangelogConfigurationYaml yamlConfig, string configPath) + private ChangelogConfiguration? ParseConfiguration( + IDiagnosticsCollector collector, + ChangelogConfigurationYaml yamlConfig, + string configPath + ) { var validProductIds = configurationContext.ProductsConfiguration.Products.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase); @@ -132,7 +135,10 @@ internal static ChangelogConfigurationYaml DeserializeConfiguration(string yaml) { if (ChangelogEntryTypeExtensions.TryParse(typeName, out _, ignoreCase: true, allowMatchingMetadataAttribute: true)) continue; - collector.EmitError(configPath, $"Type '{typeName}' in pivot.types is not a valid type. Valid types: {string.Join(", ", ChangelogConfiguration.DefaultTypes)}"); + collector.EmitError( + configPath, + $"Type '{typeName}' in pivot.types is not a valid type. Valid types: {string.Join(", ", ChangelogConfiguration.DefaultTypes)}" + ); return null; } @@ -142,7 +148,10 @@ internal static ChangelogConfigurationYaml DeserializeConfiguration(string yaml) var requiredTypeName = requiredType.ToStringFast(true); if (yamlConfig.Pivot.Types.Keys.Any(k => k.Equals(requiredTypeName, StringComparison.OrdinalIgnoreCase))) continue; - collector.EmitError(configPath, $"Required type '{requiredTypeName}' is missing from pivot.types. Required types: {string.Join(", ", ChangelogConfiguration.RequiredTypes.Select(t => t.ToStringFast(true)))}"); + collector.EmitError( + configPath, + $"Required type '{requiredTypeName}' is missing from pivot.types. Required types: {string.Join(", ", ChangelogConfiguration.RequiredTypes.Select(t => t.ToStringFast(true)))}" + ); return null; } @@ -153,16 +162,29 @@ internal static ChangelogConfigurationYaml DeserializeConfiguration(string yaml) continue; if (!typeName.Equals(ChangelogEntryType.BreakingChange.ToStringFast(true), StringComparison.OrdinalIgnoreCase)) { - collector.EmitError(configPath, $"Type '{typeName}' has subtypes defined, but subtypes are only allowed for 'breaking-change' type."); + collector.EmitError( + configPath, + $"Type '{typeName}' has subtypes defined, but subtypes are only allowed for 'breaking-change' type." + ); return null; } // Validate subtype values against enum foreach (var subtypeName in typeEntry.Subtypes.Keys) { - if (ChangelogEntrySubtypeExtensions.TryParse(subtypeName, out _, ignoreCase: true, allowMatchingMetadataAttribute: true)) + if ( + ChangelogEntrySubtypeExtensions.TryParse( + subtypeName, + out _, + ignoreCase: true, + allowMatchingMetadataAttribute: true + ) + ) continue; - collector.EmitError(configPath, $"Subtype '{subtypeName}' in pivot.types.{typeName}.subtypes is not a valid subtype. Valid subtypes: {string.Join(", ", ChangelogConfiguration.DefaultSubtypes)}"); + collector.EmitError( + configPath, + $"Subtype '{subtypeName}' in pivot.types.{typeName}.subtypes is not a valid subtype. Valid subtypes: {string.Join(", ", ChangelogConfiguration.DefaultSubtypes)}" + ); return null; } } @@ -178,9 +200,19 @@ internal static ChangelogConfigurationYaml DeserializeConfiguration(string yaml) // Validate subtypes against enum values using TryParse foreach (var subtypeName in yamlConfig.Pivot.Subtypes.Keys) { - if (!ChangelogEntrySubtypeExtensions.TryParse(subtypeName, out _, ignoreCase: true, allowMatchingMetadataAttribute: true)) + if ( + !ChangelogEntrySubtypeExtensions.TryParse( + subtypeName, + out _, + ignoreCase: true, + allowMatchingMetadataAttribute: true + ) + ) { - collector.EmitError(configPath, $"Subtype '{subtypeName}' in pivot.subtypes is not a valid subtype. Valid subtypes: {string.Join(", ", ChangelogConfiguration.DefaultSubtypes)}"); + collector.EmitError( + configPath, + $"Subtype '{subtypeName}' in pivot.subtypes is not a valid subtype. Valid subtypes: {string.Join(", ", ChangelogConfiguration.DefaultSubtypes)}" + ); return null; } } @@ -212,7 +244,10 @@ internal static ChangelogConfigurationYaml DeserializeConfiguration(string yaml) if (validProductIds.Contains(productId)) continue; var availableProducts = string.Join(", ", validProductIds.OrderBy(p => p)); - collector.EmitError(configPath, $"Product '{specParts[0]}' in pivot.products is not in the list of available products from config/products.yml. Available products: {availableProducts}"); + collector.EmitError( + configPath, + $"Product '{specParts[0]}' in pivot.products is not in the list of available products from config/products.yml. Available products: {availableProducts}" + ); return null; } } @@ -243,7 +278,10 @@ internal static ChangelogConfigurationYaml DeserializeConfiguration(string yaml) { if (!LifecycleExtensions.TryParse(lifecycleStr, out var lifecycle, ignoreCase: true, allowMatchingMetadataAttribute: true)) { - collector.EmitError(configPath, $"Lifecycle '{lifecycleStr}' in changelog.yml is not valid. Valid lifecycles: {string.Join(", ", ChangelogConfiguration.DefaultLifecycles.Select(l => l.ToStringFast(true)))}"); + collector.EmitError( + configPath, + $"Lifecycle '{lifecycleStr}' in changelog.yml is not valid. Valid lifecycles: {string.Join(", ", ChangelogConfiguration.DefaultLifecycles.Select(l => l.ToStringFast(true)))}" + ); return null; } parsedLifecycles.Add(lifecycle); @@ -263,7 +301,10 @@ internal static ChangelogConfigurationYaml DeserializeConfiguration(string yaml) if (!validProductIds.Contains(normalizedProductId)) { var availableProducts = string.Join(", ", validProductIds.OrderBy(p => p)); - collector.EmitError(configPath, $"Product '{productId}' in changelog.yml is not in the list of available products from config/products.yml. Available products: {availableProducts}"); + collector.EmitError( + configPath, + $"Product '{productId}' in changelog.yml is not in the list of available products from config/products.yml. Available products: {availableProducts}" + ); return null; } if (configurationContext.ProductsConfiguration.Products.TryGetValue(normalizedProductId, out var product)) @@ -306,7 +347,14 @@ internal static ChangelogConfigurationYaml DeserializeConfiguration(string yaml) var filenameStrategy = FilenameStrategy.Timestamp; if (!string.IsNullOrWhiteSpace(yamlConfig.Filename)) { - if (!FilenameStrategyExtensions.TryParse(yamlConfig.Filename, out var parsed, ignoreCase: true, allowMatchingMetadataAttribute: true)) + if ( + !FilenameStrategyExtensions.TryParse( + yamlConfig.Filename, + out var parsed, + ignoreCase: true, + allowMatchingMetadataAttribute: true + ) + ) { var valid = string.Join(", ", FilenameStrategyExtensions.GetValues().Select(v => v.ToStringFast(true))); collector.EmitError(configPath, $"filename: '{yamlConfig.Filename}' is not valid. Use one of: {valid}"); @@ -318,7 +366,8 @@ internal static ChangelogConfigurationYaml DeserializeConfiguration(string yaml) var labelToAreasReadOnly = labelToAreas?.ToDictionary( kvp => kvp.Key, kvp => (IReadOnlyList)kvp.Value, - StringComparer.OrdinalIgnoreCase); + StringComparer.OrdinalIgnoreCase + ); return new ChangelogConfiguration { @@ -346,15 +395,14 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) Dictionary? types = null; if (yamlPivot.Types != null) { - types = yamlPivot.Types.ToDictionary( - kvp => kvp.Key, - kvp => kvp.Value == null - ? null - : new TypeEntry - { - Labels = kvp.Value.Labels, - Subtypes = ConvertLenientDictToStringDict(kvp.Value.Subtypes) - }); + types = + yamlPivot.Types.ToDictionary( + kvp => kvp.Key, + kvp => + kvp.Value == null + ? null + : new TypeEntry { Labels = kvp.Value.Labels, Subtypes = ConvertLenientDictToStringDict(kvp.Value.Subtypes) } + ); } return new PivotConfiguration @@ -376,10 +424,7 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) if (source == null || source.Count == 0) return null; - return source.ToDictionary( - kvp => kvp.Key, - kvp => JoinLenientList(kvp.Value) - ); + return source.ToDictionary(kvp => kvp.Key, kvp => JoinLenientList(kvp.Value)); } /// @@ -392,7 +437,8 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) IDiagnosticsCollector collector, ProductsConfigYaml yaml, string configPath, - HashSet validProductIds) + HashSet validProductIds + ) { // Validate available products List? available = null; @@ -406,7 +452,10 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) if (!validProductIds.Contains(normalizedProductId)) { var availableProducts = string.Join(", ", validProductIds.OrderBy(p => p)); - collector.EmitError(configPath, $"Product '{productId}' in products_config.available is not in the list of available products from config/products.yml. Available products: {availableProducts}"); + collector.EmitError( + configPath, + $"Product '{productId}' in products_config.available is not in the list of available products from config/products.yml. Available products: {availableProducts}" + ); return null; } available.Add(normalizedProductId); @@ -430,35 +479,38 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) if (!validProductIds.Contains(normalizedProductId)) { var availableProducts = string.Join(", ", validProductIds.OrderBy(p => p)); - collector.EmitError(configPath, $"Product '{defaultYaml.Product}' in products_config.default is not in the list of available products from config/products.yml. Available products: {availableProducts}"); + collector.EmitError( + configPath, + $"Product '{defaultYaml.Product}' in products_config.default is not in the list of available products from config/products.yml. Available products: {availableProducts}" + ); return null; } - defaultProducts.Add(new DefaultProduct - { - Product = normalizedProductId, - Lifecycle = defaultYaml.Lifecycle ?? "ga" - }); + defaultProducts.Add(new DefaultProduct { Product = normalizedProductId, Lifecycle = defaultYaml.Lifecycle ?? "ga" }); } } - return new ProductsConfig - { - Available = available, - Default = defaultProducts - }; + return new ProductsConfig { Available = available, Default = defaultProducts }; } - private static BundleConfiguration? ParseBundleConfiguration(IDiagnosticsCollector collector, string configPath, BundleConfigurationYaml yaml) + private static BundleConfiguration? ParseBundleConfiguration( + IDiagnosticsCollector collector, + string configPath, + BundleConfigurationYaml yaml + ) { if (yaml.Resolve != null) - collector.EmitWarning(configPath, "bundle.resolve is deprecated and ignored. Resolved bundles are now the only format. Remove 'resolve' from bundle in changelog.yml."); + collector.EmitWarning( + configPath, + "bundle.resolve is deprecated and ignored. Resolved bundles are now the only format. Remove 'resolve' from bundle in changelog.yml." + ); if (!string.IsNullOrWhiteSpace(yaml.Repo) && yaml.Repo.Contains('+', StringComparison.Ordinal)) { collector.EmitError( configPath, - "bundle.repo must name a single GitHub repository. Remove '+' merged-repo syntax from bundle.repo."); + "bundle.repo must name a single GitHub repository. Remove '+' merged-repo syntax from bundle.repo." + ); return null; } @@ -471,7 +523,8 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) { collector.EmitError( configPath, - $"bundle.profiles.{kvp.Key}.repo must name a single GitHub repository. Remove '+' merged-repo syntax."); + $"bundle.profiles.{kvp.Key}.repo must name a single GitHub repository. Remove '+' merged-repo syntax." + ); return null; } } @@ -488,12 +541,12 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) continue; var trimmed = v.Trim(); - if (trimmed.IndexOf('/') < 0 || - trimmed.IndexOf('/') != trimmed.LastIndexOf('/')) + if (trimmed.IndexOf('/') < 0 || trimmed.IndexOf('/') != trimmed.LastIndexOf('/')) { collector.EmitError( configPath, - $"bundle.link_allow_repos: each entry must be exactly 'owner/repo' (one slash). Invalid: '{v}'."); + $"bundle.link_allow_repos: each entry must be exactly 'owner/repo' (one slash). Invalid: '{v}'." + ); return null; } @@ -502,7 +555,8 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) { collector.EmitError( configPath, - $"bundle.link_allow_repos: each entry must be exactly 'owner/repo' (one slash). Invalid: '{v}'."); + $"bundle.link_allow_repos: each entry must be exactly 'owner/repo' (one slash). Invalid: '{v}'." + ); return null; } @@ -515,23 +569,26 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) Dictionary? profiles = null; if (yaml.Profiles is { Count: > 0 }) { - profiles = yaml.Profiles.ToDictionary( - kvp => kvp.Key, - kvp => kvp.Value is null - ? new BundleProfile() - : new BundleProfile - { - Products = kvp.Value.Products, - Output = kvp.Value.Output, - OutputProducts = kvp.Value.OutputProducts, - Description = kvp.Value.Description, - Repo = kvp.Value.Repo, - Owner = kvp.Value.Owner, - Branch = kvp.Value.Branch, - HideFeatures = kvp.Value.HideFeatures?.Values, - ReleaseDates = kvp.Value.ReleaseDates, - Source = kvp.Value.Source - }); + profiles = + yaml.Profiles.ToDictionary( + kvp => kvp.Key, + kvp => + kvp.Value is null + ? new BundleProfile() + : new BundleProfile + { + Products = kvp.Value.Products, + Output = kvp.Value.Output, + OutputProducts = kvp.Value.OutputProducts, + Description = kvp.Value.Description, + Repo = kvp.Value.Repo, + Owner = kvp.Value.Owner, + Branch = kvp.Value.Branch, + HideFeatures = kvp.Value.HideFeatures?.Values, + ReleaseDates = kvp.Value.ReleaseDates, + Source = kvp.Value.Source + } + ); } return new BundleConfiguration @@ -553,15 +610,18 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) /// Loads changelog configuration from a specific path, treating a missing file as a hard error. /// Used in profile mode when an explicit config path was provided (e.g. in tests). /// - public async Task LoadChangelogConfigurationRequired(IDiagnosticsCollector collector, string configPath, Cancel ctx) + public async Task LoadChangelogConfigurationRequired( + IDiagnosticsCollector collector, + string configPath, + Cancel ctx + ) { if (!fileSystem.File.Exists(configPath)) { collector.EmitError( configPath, - $"Changelog configuration file not found at '{configPath}'. " + - "Either run 'docs-builder changelog init' to create one, " + - "or re-run from the folder where changelog.yml exists." + $"Changelog configuration file not found at '{configPath}'. " + "Either run 'docs-builder changelog init' to create one, " + + "or re-run from the folder where changelog.yml exists." ); return null; } @@ -598,11 +658,7 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) public async Task LoadChangelogConfigurationForProfileMode(IDiagnosticsCollector collector, Cancel ctx) { var cwd = fileSystem.Directory.GetCurrentDirectory(); - var candidates = new[] - { - fileSystem.Path.Join(cwd, "changelog.yml"), - fileSystem.Path.Join(cwd, "docs", "changelog.yml") - }; + var candidates = new[] { fileSystem.Path.Join(cwd, "changelog.yml"), fileSystem.Path.Join(cwd, "docs", "changelog.yml") }; var foundPath = candidates.FirstOrDefault(fileSystem.File.Exists); @@ -611,9 +667,9 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) collector.EmitError( string.Empty, "changelog.yml not found. Profile-based commands require a changelog configuration file. " + - "Either run 'docs-builder changelog init' to create one, " + - "or re-run this command from the folder where changelog.yml exists " + - "(e.g. the project root if the file is at docs/changelog.yml)." + "Either run 'docs-builder changelog init' to create one, " + + "or re-run this command from the folder where changelog.yml exists " + + "(e.g. the project root if the file is at docs/changelog.yml)." ); return null; } @@ -645,7 +701,8 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) IDiagnosticsCollector collector, RulesConfigurationYaml? rulesYaml, string configPath, - HashSet validProductIds) + HashSet validProductIds + ) { if (rulesYaml == null) return null; @@ -675,7 +732,10 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) // Parse publish rules — emit deprecation warning when present if (rulesYaml.Publish != null) - collector.EmitWarning(configPath, "rules.publish is deprecated and no longer used by the changelog render command. Move type/area filtering to rules.bundle, which applies at bundle time instead of render time."); + collector.EmitWarning( + configPath, + "rules.publish is deprecated and no longer used by the changelog render command. Move type/area filtering to rules.bundle, which applies at bundle time instead of render time." + ); // Note: rules.publish is no longer used by changelog render; set to null so it's never applied // The warning above alerts users they need to migrate to rules.bundle @@ -685,7 +745,8 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) Match = globalMatch, Create = createRules, Bundle = bundleRules, - Publish = null // rules.publish is retired; filtering happens at bundle time via rules.bundle + Publish = + null // rules.publish is retired; filtering happens at bundle time via rules.bundle }; } @@ -694,7 +755,8 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) BundleRulesYaml? yaml, string configPath, HashSet validProductIds, - MatchMode inheritedMatch) + MatchMode inheritedMatch + ) { if (yaml == null) return null; @@ -702,16 +764,31 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) // Validate mutual exclusivity for products if (yaml.ExcludeProducts?.Values is { Count: > 0 } && yaml.IncludeProducts?.Values is { Count: > 0 }) { - collector.EmitError(configPath, "rules.bundle: cannot have both 'exclude_products' and 'include_products'. Use one or the other."); + collector.EmitError( + configPath, + "rules.bundle: cannot have both 'exclude_products' and 'include_products'. Use one or the other." + ); return null; } // Parse and validate product lists - var excludeProducts = ParseAndValidateProductList(collector, yaml.ExcludeProducts, configPath, validProductIds, "rules.bundle.exclude_products"); + var excludeProducts = ParseAndValidateProductList( + collector, + yaml.ExcludeProducts, + configPath, + validProductIds, + "rules.bundle.exclude_products" + ); if (excludeProducts == null && collector.Errors > 0) return null; - var includeProducts = ParseAndValidateProductList(collector, yaml.IncludeProducts, configPath, validProductIds, "rules.bundle.include_products"); + var includeProducts = ParseAndValidateProductList( + collector, + yaml.IncludeProducts, + configPath, + validProductIds, + "rules.bundle.include_products" + ); if (includeProducts == null && collector.Errors > 0) return null; @@ -722,7 +799,10 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) var parsed = ParseMatchMode(yaml.MatchProducts); if (parsed == null) { - collector.EmitError(configPath, $"rules.bundle.match_products: '{yaml.MatchProducts}' is not valid. Use 'any', 'all', or 'conjunction'."); + collector.EmitError( + configPath, + $"rules.bundle.match_products: '{yaml.MatchProducts}' is not valid. Use 'any', 'all', or 'conjunction'." + ); return null; } matchProducts = parsed.Value; @@ -735,7 +815,10 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) var parsed = ParseMatchMode(yaml.MatchAreas); if (parsed == null) { - collector.EmitError(configPath, $"rules.bundle.match_areas: '{yaml.MatchAreas}' is not valid. Use 'any', 'all', or 'conjunction'."); + collector.EmitError( + configPath, + $"rules.bundle.match_areas: '{yaml.MatchAreas}' is not valid. Use 'any', 'all', or 'conjunction'." + ); return null; } matchAreas = parsed.Value; @@ -768,7 +851,10 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) if (!validProductIds.Contains(normalizedProductId)) { var availableProducts = string.Join(", ", validProductIds.OrderBy(p => p)); - collector.EmitError(configPath, $"rules.bundle.products: '{productId}' not in available products. Available: {availableProducts}"); + collector.EmitError( + configPath, + $"rules.bundle.products: '{productId}' not in available products. Available: {availableProducts}" + ); return null; } @@ -778,16 +864,31 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) // Validate mutual exclusivity for products within this context if (productYaml.ExcludeProducts?.Values is { Count: > 0 } && productYaml.IncludeProducts?.Values is { Count: > 0 }) { - collector.EmitError(configPath, $"rules.bundle.products.{normalizedProductId}: cannot have both 'exclude_products' and 'include_products'. Use one or the other."); + collector.EmitError( + configPath, + $"rules.bundle.products.{normalizedProductId}: cannot have both 'exclude_products' and 'include_products'. Use one or the other." + ); return null; } // Parse product lists for this context - var contextExcludeProducts = ParseAndValidateProductList(collector, productYaml.ExcludeProducts, configPath, validProductIds, $"rules.bundle.products.{normalizedProductId}.exclude_products"); + var contextExcludeProducts = ParseAndValidateProductList( + collector, + productYaml.ExcludeProducts, + configPath, + validProductIds, + $"rules.bundle.products.{normalizedProductId}.exclude_products" + ); if (contextExcludeProducts == null && collector.Errors > 0) return null; - var contextIncludeProducts = ParseAndValidateProductList(collector, productYaml.IncludeProducts, configPath, validProductIds, $"rules.bundle.products.{normalizedProductId}.include_products"); + var contextIncludeProducts = ParseAndValidateProductList( + collector, + productYaml.IncludeProducts, + configPath, + validProductIds, + $"rules.bundle.products.{normalizedProductId}.include_products" + ); if (contextIncludeProducts == null && collector.Errors > 0) return null; @@ -801,7 +902,10 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) var parsed = ParseMatchMode(productYaml.MatchProducts); if (parsed == null) { - collector.EmitError(configPath, $"rules.bundle.products.{normalizedProductId}.match_products: '{productYaml.MatchProducts}' is not valid. Use 'any', 'all', or 'conjunction'."); + collector.EmitError( + configPath, + $"rules.bundle.products.{normalizedProductId}.match_products: '{productYaml.MatchProducts}' is not valid. Use 'any', 'all', or 'conjunction'." + ); return null; } contextMatchProducts = parsed.Value; @@ -810,25 +914,30 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) // Validate per-product ineffective patterns if (contextMatchProducts == MatchMode.Any && contextIncludeProducts is { Count: > 0 }) { - collector.EmitWarning(configPath, + collector.EmitWarning( + configPath, $"Configuration pattern 'match_products: any' with 'include_products' in per-product rule '{normalizedProductId}' provides no selective filtering. " + - "Consider 'match_products: all' for strict filtering or 'exclude_products' for exclusion-based filtering. " + - "Refer to https://github.com/elastic/docs-builder/blob/main/docs/contribute/configure-changelogs-ref.md"); + "Consider 'match_products: all' for strict filtering or 'exclude_products' for exclusion-based filtering. " + + "Refer to https://github.com/elastic/docs-builder/blob/main/docs/contribute/configure-changelogs-ref.md" + ); } // Detect disjoint products in per-product include_products if (contextIncludeProducts is { Count: > 1 }) { - var disjointProducts = contextIncludeProducts.Where(p => - !string.Equals(p, normalizedProductId, StringComparison.OrdinalIgnoreCase)).ToList(); + var disjointProducts = contextIncludeProducts.Where( + p => !string.Equals(p, normalizedProductId, StringComparison.OrdinalIgnoreCase) + ).ToList(); if (disjointProducts.Count > 0) { - collector.EmitHint(configPath, + collector.EmitHint( + configPath, $"Per-product rule '{normalizedProductId}' includes disjoint products [{string.Join(", ", disjointProducts)}] " + - "which cannot be included due to single-product rule resolution. " + - "Use separate bundles (each with a single product in output_products or profile output_products), or multi-product changelogs instead. " + - "Refer to https://github.com/elastic/docs-builder/blob/main/docs/contribute/configure-changelogs.md"); + "which cannot be included due to single-product rule resolution. " + + "Use separate bundles (each with a single product in output_products or profile output_products), or multi-product changelogs instead. " + + "Refer to https://github.com/elastic/docs-builder/blob/main/docs/contribute/configure-changelogs.md" + ); } } @@ -839,7 +948,10 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) var parsedMode = ParseMatchMode(productYaml.MatchAreas); if (parsedMode == null) { - collector.EmitError(configPath, $"rules.bundle.products.{normalizedProductId}.match_areas: '{productYaml.MatchAreas}' is not valid. Use 'any', 'all', or 'conjunction'."); + collector.EmitError( + configPath, + $"rules.bundle.products.{normalizedProductId}.match_areas: '{productYaml.MatchAreas}' is not valid. Use 'any', 'all', or 'conjunction'." + ); return null; } productMatchAreas = parsedMode.Value; @@ -853,7 +965,13 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) ExcludeAreas = productYaml.ExcludeAreas, IncludeAreas = productYaml.IncludeAreas }; - var productBlocker = ParsePublishBlockerFromYaml(collector, productBlockerYaml, configPath, $"rules.bundle.products.{normalizedProductId}", productMatchAreas); + var productBlocker = ParsePublishBlockerFromYaml( + collector, + productBlockerYaml, + configPath, + $"rules.bundle.products.{normalizedProductId}", + productMatchAreas + ); if (productBlocker == null && collector.Errors > 0) return null; @@ -877,9 +995,11 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) var hasGlobalProductFilters = (excludeProducts?.Count ?? 0) > 0 || (includeProducts?.Count ?? 0) > 0; if (hasGlobalProductFilters || blocker != null) { - collector.EmitHint(configPath, + collector.EmitHint( + configPath, "rules.bundle: When 'products' is present, global include_products, exclude_products, and type/area rules are not applied for filtering; configure filters under each product key or use global-only rules.bundle (no 'products' section). " + - "See: https://github.com/elastic/docs-builder/blob/main/docs/contribute/configure-changelogs-ref.md#rules-bundle"); + "See: https://github.com/elastic/docs-builder/blob/main/docs/contribute/configure-changelogs-ref.md#rules-bundle" + ); } } @@ -898,7 +1018,8 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) YamlLenientList? list, string configPath, HashSet validProductIds, - string fieldPath) + string fieldPath + ) { if (list?.Values is not { Count: > 0 } values) return null; @@ -910,7 +1031,10 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) if (!validProductIds.Contains(normalizedId)) { var availableProducts = string.Join(", ", validProductIds.OrderBy(p => p)); - collector.EmitError(configPath, $"{fieldPath}: '{rawId}' is not in the list of available products. Available products: {availableProducts}"); + collector.EmitError( + configPath, + $"{fieldPath}: '{rawId}' is not in the list of available products. Available products: {availableProducts}" + ); return null; } result.Add(normalizedId); @@ -924,7 +1048,8 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) string configPath, HashSet validProductIds, string path, - MatchMode inheritedMatch) + MatchMode inheritedMatch + ) { if (yaml == null) return null; @@ -966,11 +1091,21 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) if (!validProductIds.Contains(normalizedProductId)) { var availableProducts = string.Join(", ", validProductIds.OrderBy(p => p)); - collector.EmitError(configPath, $"{path}.products: '{productId}' not in available products. Available: {availableProducts}"); + collector.EmitError( + configPath, + $"{path}.products: '{productId}' not in available products. Available: {availableProducts}" + ); return null; } - var productRules = ParseCreateRules(collector, productYaml, configPath, validProductIds, $"{path}.products.{normalizedProductId}", match); + var productRules = ParseCreateRules( + collector, + productYaml, + configPath, + validProductIds, + $"{path}.products.{normalizedProductId}", + match + ); if (productRules == null && collector.Errors > 0) return null; if (productRules != null) @@ -979,13 +1114,7 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) } } - return new CreateRules - { - Labels = labels, - Mode = mode, - Match = match, - ByProduct = byProduct - }; + return new CreateRules { Labels = labels, Mode = mode, Match = match, ByProduct = byProduct }; } private PublishRules? ParsePublishRules( @@ -994,7 +1123,8 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) string configPath, HashSet validProductIds, string path, - MatchMode inheritedMatch) + MatchMode inheritedMatch + ) { if (yaml == null) return null; @@ -1006,7 +1136,10 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) var parsed = ParseMatchMode(yaml.MatchAreas); if (parsed == null) { - collector.EmitError(configPath, $"{path}.match_areas: '{yaml.MatchAreas}' is not valid. Use 'any', 'all', or 'conjunction'."); + collector.EmitError( + configPath, + $"{path}.match_areas: '{yaml.MatchAreas}' is not valid. Use 'any', 'all', or 'conjunction'." + ); return null; } matchAreas = parsed.Value; @@ -1031,7 +1164,10 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) if (!validProductIds.Contains(normalizedProductId)) { var availableProducts = string.Join(", ", validProductIds.OrderBy(p => p)); - collector.EmitError(configPath, $"{path}.products: '{productId}' not in available products. Available: {availableProducts}"); + collector.EmitError( + configPath, + $"{path}.products: '{productId}' not in available products. Available: {availableProducts}" + ); return null; } @@ -1044,13 +1180,22 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) var parsed = ParseMatchMode(productYaml.MatchAreas); if (parsed == null) { - collector.EmitError(configPath, $"{path}.products.{normalizedProductId}.match_areas: '{productYaml.MatchAreas}' is not valid. Use 'any', 'all', or 'conjunction'."); + collector.EmitError( + configPath, + $"{path}.products.{normalizedProductId}.match_areas: '{productYaml.MatchAreas}' is not valid. Use 'any', 'all', or 'conjunction'." + ); return null; } productMatchAreas = parsed.Value; } - var productBlocker = ParsePublishBlockerFromYaml(collector, productYaml, configPath, $"{path}.products.{normalizedProductId}", productMatchAreas); + var productBlocker = ParsePublishBlockerFromYaml( + collector, + productYaml, + configPath, + $"{path}.products.{normalizedProductId}", + productMatchAreas + ); if (productBlocker == null && collector.Errors > 0) return null; if (productBlocker != null) @@ -1059,11 +1204,7 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) } } - return new PublishRules - { - Blocker = blocker, - ByProduct = byProduct - }; + return new PublishRules { Blocker = blocker, ByProduct = byProduct }; } private static PublishBlocker? ParsePublishBlockerFromYaml( @@ -1071,7 +1212,8 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) PublishRulesYaml yaml, string configPath, string path, - MatchMode matchAreas) + MatchMode matchAreas + ) { // Validate mutual exclusivity for types var excludeTypes = yaml.ExcludeTypes?.Values; @@ -1136,14 +1278,13 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) }; } - private static MatchMode? ParseMatchMode(string? value) => - value?.ToLowerInvariant() switch - { - "any" => MatchMode.Any, - "all" => MatchMode.All, - "conjunction" => MatchMode.Conjunction, - _ => string.IsNullOrWhiteSpace(value) ? null : null - }; + private static MatchMode? ParseMatchMode(string? value) => value?.ToLowerInvariant() switch + { + "any" => MatchMode.Any, + "all" => MatchMode.All, + "conjunction" => MatchMode.Conjunction, + _ => string.IsNullOrWhiteSpace(value) ? null : null + }; /// /// Builds LabelToType mapping by inverting pivot.types entries. diff --git a/src/Elastic.Documentation.Configuration/Changelog/TypeEntryYamlConverter.cs b/src/Elastic.Documentation.Configuration/Changelog/TypeEntryYamlConverter.cs index 135a5ffd78..303ad05efa 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/TypeEntryYamlConverter.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/TypeEntryYamlConverter.cs @@ -175,9 +175,7 @@ public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializ foreach (var (subKey, subValue) in entry.Subtypes) { emitter.Emit(new Scalar(null, null, subKey, ScalarStyle.Plain, true, false)); - var joinedValue = subValue?.Values is { Count: > 0 } vals - ? string.Join(", ", vals) - : string.Empty; + var joinedValue = subValue?.Values is { Count: > 0 } vals ? string.Join(", ", vals) : string.Empty; emitter.Emit(new Scalar(null, null, joinedValue, ScalarStyle.Plain, true, false)); } diff --git a/src/Elastic.Documentation.Configuration/Changelog/VersionLifecycleInference.cs b/src/Elastic.Documentation.Configuration/Changelog/VersionLifecycleInference.cs index 2cfe125a23..7f798dcdfd 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/VersionLifecycleInference.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/VersionLifecycleInference.cs @@ -38,7 +38,10 @@ public static string InferLifecycle(string version) "alpha" => "preview", "preview" => "preview", "rc" => "ga", // Release candidate = GA - _ => "preview" // Unknown prerelease = preview + + _ => + "preview" // Unknown prerelease = preview + }; } } diff --git a/src/Elastic.Documentation.Configuration/Changelog/YamlLenientListConverter.cs b/src/Elastic.Documentation.Configuration/Changelog/YamlLenientListConverter.cs index 8facb13be3..2e46fa4ef0 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/YamlLenientListConverter.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/YamlLenientListConverter.cs @@ -27,9 +27,7 @@ public class YamlLenientListConverter : IYamlTypeConverter if (string.IsNullOrEmpty(scalar.Value) || scalar.Value == "~") return new YamlLenientList(null); - var items = scalar.Value - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .ToList(); + var items = scalar.Value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); return new YamlLenientList(items.Count > 0 ? items : null); } diff --git a/src/Elastic.Documentation.Configuration/ChangelogTemplateSeeder.cs b/src/Elastic.Documentation.Configuration/ChangelogTemplateSeeder.cs index d8a684e3b1..1ec54a711b 100644 --- a/src/Elastic.Documentation.Configuration/ChangelogTemplateSeeder.cs +++ b/src/Elastic.Documentation.Configuration/ChangelogTemplateSeeder.cs @@ -25,7 +25,8 @@ public static string ApplyBundleRepoSeed(string content, string? ownerCli, strin if (!string.IsNullOrWhiteSpace(resolvedRepo) && string.IsNullOrWhiteSpace(resolvedOwner)) resolvedOwner = "elastic"; - var shouldSeed = !string.IsNullOrWhiteSpace(resolvedOwner) && !string.IsNullOrWhiteSpace(resolvedRepo) + var shouldSeed = !string.IsNullOrWhiteSpace(resolvedOwner) + && !string.IsNullOrWhiteSpace(resolvedRepo) && (!string.IsNullOrWhiteSpace(ownerCli) || !string.IsNullOrWhiteSpace(repoCli) || gitMatched); var eol = content.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; @@ -38,16 +39,18 @@ public static string ApplyBundleRepoSeed(string content, string? ownerCli, strin if (content.Contains(placeholderWithEol, StringComparison.Ordinal)) return content.Replace(placeholderWithEol, block, StringComparison.Ordinal); - return content.Replace( - Placeholder, - shouldSeed ? block.TrimEnd('\r', '\n') : string.Empty, - StringComparison.Ordinal - ); + return content.Replace(Placeholder, shouldSeed ? block.TrimEnd('\r', '\n') : string.Empty, StringComparison.Ordinal); } internal static string QuoteForYaml(string value) => - value.Contains(':') || value.Contains(' ') || value.Contains('#') || value.Contains('"') - || value.Contains('\\') || value.Contains('\n') || value.Contains('\r') || value.Contains('\t') + value.Contains(':') + || value.Contains(' ') + || value.Contains('#') + || value.Contains('"') + || value.Contains('\\') + || value.Contains('\n') + || value.Contains('\r') + || value.Contains('\t') ? $"\"{value .Replace("\\", "\\\\") .Replace("\"", "\\\"") diff --git a/src/Elastic.Documentation.Configuration/Codex/CodexDocumentationSetReference.cs b/src/Elastic.Documentation.Configuration/Codex/CodexDocumentationSetReference.cs index a81f77f014..38ad1212b2 100644 --- a/src/Elastic.Documentation.Configuration/Codex/CodexDocumentationSetReference.cs +++ b/src/Elastic.Documentation.Configuration/Codex/CodexDocumentationSetReference.cs @@ -94,17 +94,17 @@ public string GetGitUrl() var origin = ResolvedOrigin; // If origin is already a full URL, return it as-is - if (origin.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || - origin.StartsWith("git@", StringComparison.OrdinalIgnoreCase)) + if ( + origin.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || + origin.StartsWith("git@", StringComparison.OrdinalIgnoreCase) + ) return origin; // Otherwise, construct the URL from the short form (e.g., "elastic/repo-name") if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"))) { var token = Environment.GetEnvironmentVariable("GITHUB_TOKEN"); - return !string.IsNullOrEmpty(token) - ? $"https://oauth2:{token}@github.com/{origin}.git" - : $"https://github.com/{origin}.git"; + return !string.IsNullOrEmpty(token) ? $"https://oauth2:{token}@github.com/{origin}.git" : $"https://github.com/{origin}.git"; } return $"git@github.com:{origin}.git"; diff --git a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs index 1e565df9ba..5e966d10c5 100644 --- a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs +++ b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs @@ -65,19 +65,22 @@ public ConfigurationFileProvider( else { string[] spotChecks = ["navigation.yml", "versions.yml", "products.yml", "assembler.yml", "search.yml"]; - var defaultSource = - fileSystem.Directory.Exists(LocalConfigurationDirectory) - && spotChecks.All(f => fileSystem.File.Exists(Path.Join(LocalConfigurationDirectory, f))) + var defaultSource = fileSystem.Directory.Exists(LocalConfigurationDirectory) && + spotChecks.All(f => fileSystem.File.Exists(Path.Join(LocalConfigurationDirectory, f))) ? ConfigurationSource.Local : ConfigurationSource.Embedded; ConfigurationSource = defaultSource; } if (ConfigurationSource == ConfigurationSource.Local && !fileSystem.Directory.Exists(LocalConfigurationDirectory)) - throw new Exception($"Required directory form {nameof(ConfigurationSource)}.{nameof(ConfigurationSource.Local)} directory {LocalConfigurationDirectory} does not exist."); + throw new Exception( + $"Required directory form {nameof(ConfigurationSource)}.{nameof(ConfigurationSource.Local)} directory {LocalConfigurationDirectory} does not exist." + ); if (ConfigurationSource == ConfigurationSource.Remote && !fileSystem.Directory.Exists(AppDataConfigurationDirectory)) - throw new Exception($"Required directory form {nameof(ConfigurationSource)}.{nameof(ConfigurationSource.Remote)} directory {AppDataConfigurationDirectory} does not exist."); + throw new Exception( + $"Required directory form {nameof(ConfigurationSource)}.{nameof(ConfigurationSource.Remote)} directory {AppDataConfigurationDirectory} does not exist." + ); var path = GetAppDataPath("git-ref.txt"); if (_fileSystem.File.Exists(path)) @@ -87,19 +90,28 @@ public ConfigurationFileProvider( if (ConfigurationSource == ConfigurationSource.Remote) { - _logger.LogInformation("{ConfigurationSource}: git ref '{GitReference}', in {Directory}", - $"{nameof(ConfigurationSource)}.{nameof(ConfigurationSource.Remote)}", GitReference, AppDataConfigurationDirectory); + _logger.LogInformation( + "{ConfigurationSource}: git ref '{GitReference}', in {Directory}", + $"{nameof(ConfigurationSource)}.{nameof(ConfigurationSource.Remote)}", + GitReference, + AppDataConfigurationDirectory + ); } if (ConfigurationSource == ConfigurationSource.Local) { - _logger.LogInformation("{ConfigurationSource}: located {Directory}", - $"{nameof(ConfigurationSource)}.{nameof(ConfigurationSource.Local)}", LocalConfigurationDirectory); + _logger.LogInformation( + "{ConfigurationSource}: located {Directory}", + $"{nameof(ConfigurationSource)}.{nameof(ConfigurationSource.Local)}", + LocalConfigurationDirectory + ); } if (ConfigurationSource == ConfigurationSource.Embedded) { - _logger.LogInformation("{ConfigurationSource} using embedded in binary configuration", - $"{nameof(ConfigurationSource)}.{nameof(ConfigurationSource.Embedded)}"); + _logger.LogInformation( + "{ConfigurationSource} using embedded in binary configuration", + $"{nameof(ConfigurationSource)}.{nameof(ConfigurationSource.Embedded)}" + ); } VersionFile = CreateTemporaryConfigurationFile("versions.yml"); @@ -171,7 +183,6 @@ public IFileInfo CreateNavigationFile(AssemblyConfiguration configuration) spacing = -1; reindenting = -1; } - else if (spacing != -1 && Regex.IsMatch(line, $@"^(\s{{{spacing + 3},}})\S")) { var matches = Regex.Match(line, $@"^(?\s{{{spacing}}})(?.+)$"); @@ -192,10 +203,14 @@ public IFileInfo CreateNavigationFile(AssemblyConfiguration configuration) _fileSystem.File.AppendAllLines(tempFile, [line]); } - if (configuration.AvailableRepositories.TryGetValue("docs-builder", out var docsBuildRepository) && docsBuildRepository is { Skip: false, Path: not null }) + if ( + configuration.AvailableRepositories.TryGetValue("docs-builder", out var docsBuildRepository) && + docsBuildRepository is { Skip: false, Path: not null } + ) { // language=yaml - _fileSystem.File.AppendAllText(tempFile, + _fileSystem.File.AppendAllText( + tempFile, """ - toc: docs-builder:// @@ -206,12 +221,11 @@ public IFileInfo CreateNavigationFile(AssemblyConfiguration configuration) children: - toc: docs-builder://development/link-validation path_prefix: reference/docs-builder/dev/link-val - """); + """ + ); } NavigationFile = _fileSystem.FileInfo.New(tempFile); return NavigationFile; - - } private IFileInfo CreateTemporaryConfigurationFile(string fileName, string? fallback = null) @@ -282,14 +296,21 @@ private StreamReader GetEmbeddedStream(string fileName, string? fallback = null) public static class ConfigurationFileProviderServiceCollectionExtensions { - public static IServiceCollection AddConfigurationFileProvider(this IServiceCollection services, + public static IServiceCollection AddConfigurationFileProvider( + this IServiceCollection services, bool skipPrivateRepositories, ConfigurationSource? configurationSource, - Action configure) + Action configure + ) { using var sp = services.BuildServiceProvider(); var logFactory = sp.GetRequiredService(); - var provider = new ConfigurationFileProvider(logFactory, new ConfigurationFileSystem(), skipPrivateRepositories, configurationSource); + var provider = new ConfigurationFileProvider( + logFactory, + new ConfigurationFileSystem(), + skipPrivateRepositories, + configurationSource + ); _ = services.AddSingleton(provider); configure(services, provider); return services; diff --git a/src/Elastic.Documentation.Configuration/Converters/ApplicableToYamlConverter.cs b/src/Elastic.Documentation.Configuration/Converters/ApplicableToYamlConverter.cs index 92dd288ca2..932dbcc9d5 100644 --- a/src/Elastic.Documentation.Configuration/Converters/ApplicableToYamlConverter.cs +++ b/src/Elastic.Documentation.Configuration/Converters/ApplicableToYamlConverter.cs @@ -17,9 +17,21 @@ public class ApplicableToYamlConverter(IReadOnlyCollection productKeys) { private readonly string[] _knownKeys = [ - "stack", "deployment", "serverless", "product", // Applicability categories - "ece", "eck", "ess", "ech", "self", // Deployment options ("ech" aliasing to "ess") - "elasticsearch", "observability", "security", // Serverless flavors + "stack", + "deployment", + "serverless", + "product", // Applicability categories + + "ece", + "eck", + "ess", + "ech", + "self", // Deployment options ("ech" aliasing to "ess") + + "elasticsearch", + "observability", + "security", // Serverless flavors + .. productKeys ]; @@ -63,9 +75,7 @@ .. productKeys _ = parser.MoveNext(); } - return merged.Count > 0 - ? FinalizeApplicableTo(merged, diagnostics) - : null; + return merged.Count > 0 ? FinalizeApplicableTo(merged, diagnostics) : null; } var deserialized = rootDeserializer.Invoke(typeof(Dictionary)); @@ -96,7 +106,8 @@ .. productKeys private static void MergeAppliesToListScalarLine( Dictionary dictionary, string line, - List<(Severity, string)> diagnostics) + List<(Severity, string)> diagnostics + ) { var trimmed = line.Trim(); var colon = trimmed.IndexOf(':'); @@ -144,7 +155,11 @@ private ApplicableTo FinalizeApplicableTo(Dictionary dictionary return applicableTo; } - private static void AssignDeploymentType(Dictionary dictionary, ApplicableTo applicableTo, List<(Severity, string)> diagnostics) + private static void AssignDeploymentType( + Dictionary dictionary, + ApplicableTo applicableTo, + List<(Severity, string)> diagnostics + ) { if (!dictionary.TryGetValue("deployment", out var deploymentType)) return; @@ -156,13 +171,7 @@ private static void AssignDeploymentType(Dictionary dictionary, var applies = AppliesCollection.TryParse(deploymentTypeString, diagnostics, out var a) ? a : null; if (applies is not null) ValidateApplicabilityCollection("ess", applies, diagnostics); - applicableTo.Deployment = new DeploymentApplicability - { - Ece = applies, - Eck = applies, - Ess = applies, - Self = applies - }; + applicableTo.Deployment = new DeploymentApplicability { Ece = applies, Eck = applies, Ess = applies, Self = applies }; } else if (deploymentType is Dictionary deploymentDictionary) { @@ -171,7 +180,11 @@ private static void AssignDeploymentType(Dictionary dictionary, } } - private static void AssignProduct(Dictionary dictionary, ApplicableTo applicableTo, List<(Severity, string)> diagnostics) + private static void AssignProduct( + Dictionary dictionary, + ApplicableTo applicableTo, + List<(Severity, string)> diagnostics + ) { if (!dictionary.TryGetValue("product", out var productValue)) return; @@ -189,7 +202,11 @@ private static void AssignProduct(Dictionary dictionary, Applic applicableTo.ProductApplicability = applicability; } - private static void AssignServerless(Dictionary dictionary, ApplicableTo applicableTo, List<(Severity, string)> diagnostics) + private static void AssignServerless( + Dictionary dictionary, + ApplicableTo applicableTo, + List<(Severity, string)> diagnostics + ) { if (!dictionary.TryGetValue("serverless", out var serverless)) return; @@ -215,8 +232,11 @@ private static void AssignServerless(Dictionary dictionary, App } } - private static bool TryGetDeployment(Dictionary dictionary, List<(Severity, string)> diagnostics, - [NotNullWhen(true)] out DeploymentApplicability? applicability) + private static bool TryGetDeployment( + Dictionary dictionary, + List<(Severity, string)> diagnostics, + [NotNullWhen(true)] out DeploymentApplicability? applicability + ) { applicability = null; var d = new DeploymentApplicability(); @@ -225,7 +245,9 @@ private static bool TryGetDeployment(Dictionary dictionary, Lis var hasEss = dictionary.ContainsKey("ess"); var hasEch = dictionary.ContainsKey("ech"); if (hasEss && hasEch) - diagnostics.Add((Severity.Warning, "Both 'ess' and 'ech' are defined. Move 'ess' content into 'ech' to avoid information loss.")); + diagnostics.Add( + (Severity.Warning, "Both 'ess' and 'ech' are defined. Move 'ess' content into 'ech' to avoid information loss.") + ); var mapping = new Dictionary> { @@ -250,9 +272,11 @@ private static bool TryGetDeployment(Dictionary dictionary, Lis return true; } - private static bool TryGetProjectApplicability(Dictionary dictionary, + private static bool TryGetProjectApplicability( + Dictionary dictionary, List<(Severity, string)> diagnostics, - [NotNullWhen(true)] out ServerlessProjectApplicability? applicability) + [NotNullWhen(true)] out ServerlessProjectApplicability? applicability + ) { applicability = null; var serverlessAvailability = new ServerlessProjectApplicability(); @@ -279,9 +303,11 @@ private static bool TryGetProjectApplicability(Dictionary dicti return true; } - private static bool TryGetProductApplicability(Dictionary dictionary, + private static bool TryGetProductApplicability( + Dictionary dictionary, List<(Severity, string)> diagnostics, - [NotNullWhen(true)] out ProductApplicability? applicability) + [NotNullWhen(true)] out ProductApplicability? applicability + ) { applicability = null; var productAvailability = new ProductApplicability(); @@ -329,11 +355,14 @@ private static bool TryGetProductApplicability(Dictionary dicti return true; } - private static readonly HashSet VersionlessKeys = - ["ess", "ech", "serverless", "elasticsearch", "observability", "security"]; + private static readonly HashSet VersionlessKeys = ["ess", "ech", "serverless", "elasticsearch", "observability", "security"]; - private static bool TryGetApplicabilityOverTime(Dictionary dictionary, string key, List<(Severity, string)> diagnostics, - out AppliesCollection? availability) + private static bool TryGetApplicabilityOverTime( + Dictionary dictionary, + string key, + List<(Severity, string)> diagnostics, + out AppliesCollection? availability + ) { availability = null; if (!dictionary.TryGetValue(key, out var target)) @@ -359,53 +388,59 @@ private static void ValidateApplicabilityCollection(string key, AppliesCollectio if (VersionlessKeys.Contains(key)) { if (items.Any(a => a.Version is not null && a.Version != AllVersionsSpec.Instance)) - diagnostics.Add((Severity.Error, - $"Can't specify a version for '{key}' because this product is not versioned. Remove the version, or use 'stack:' for version-specific requirements.")); + diagnostics.Add( + (Severity.Error, $"Can't specify a version for '{key}' because this product is not versioned. Remove the version, or use 'stack:' for version-specific requirements.") + ); return; } // Rule: Only one version declaration per lifecycle var lifecycleGroups = items.GroupBy(a => a.Lifecycle).ToList(); - var lifecyclesWithMultipleVersions = lifecycleGroups - .Where(group => group.Count(a => a.Version is not null && a.Version != AllVersionsSpec.Instance) > 1) - .Select(g => g.Key) - .ToList(); + var lifecyclesWithMultipleVersions = lifecycleGroups.Where( + group => group.Count(a => a.Version is not null && a.Version != AllVersionsSpec.Instance) > 1 + ).Select(g => g.Key).ToList(); if (lifecyclesWithMultipleVersions.Count > 0) { var lifecycleNames = string.Join(", ", lifecyclesWithMultipleVersions); - diagnostics.Add((Severity.Hint, // Temporary downgrade to Hint until the currently available docs are adjusted - $"Key '{key}': Multiple version declarations found for lifecycle(s): {lifecycleNames}. Only one version per lifecycle is allowed.")); + diagnostics.Add( + (Severity.Hint, // Temporary downgrade to Hint until the currently available docs are adjusted + $"Key '{key}': Multiple version declarations found for lifecycle(s): {lifecycleNames}. Only one version per lifecycle is allowed.") + ); } // Rule: Only one item per key can use greater-than syntax - var greaterThanItems = items.Where(a => - a.Version is { Kind: VersionSpecKind.GreaterThanOrEqual } && - a.Version != AllVersionsSpec.Instance).ToList(); + var greaterThanItems = items.Where( + a => a.Version is { Kind: VersionSpecKind.GreaterThanOrEqual } && a.Version != AllVersionsSpec.Instance + ).ToList(); if (greaterThanItems.Count > 1) { - diagnostics.Add((Severity.Hint, // Temporary downgrade to Hint until the currently available docs are adjusted - $"Key '{key}': Multiple items use greater-than-or-equal syntax. Only one item per key can use this syntax.")); + diagnostics.Add( + (Severity.Hint, // Temporary downgrade to Hint until the currently available docs are adjusted + $"Key '{key}': Multiple items use greater-than-or-equal syntax. Only one item per key can use this syntax.") + ); } // Rule: In a range, the first version must be less than or equal the last version - var invalidRanges = items - .Where(a => a.Version is { Kind: VersionSpecKind.Range } && a.Version!.Min.CompareTo(a.Version.Max!) > 0) - .ToList(); + var invalidRanges = items.Where( + a => a.Version is { Kind: VersionSpecKind.Range } && a.Version!.Min.CompareTo(a.Version.Max!) > 0 + ).ToList(); if (invalidRanges.Count > 0) { - var rangeDescriptions = invalidRanges.Select(item => - $"{item.Lifecycle} ({item.Version!.Min.Major}.{item.Version.Min.Minor}-{item.Version.Max!.Major}.{item.Version.Max.Minor})"); - diagnostics.Add((Severity.Hint, // Temporary downgrade to Hint until the currently available docs are adjusted - $"Key '{key}': Invalid range(s) where first version is greater than last version: {string.Join(", ", rangeDescriptions)}.")); + var rangeDescriptions = invalidRanges.Select( + item => + $"{item.Lifecycle} ({item.Version!.Min.Major}.{item.Version.Min.Minor}-{item.Version.Max!.Major}.{item.Version.Max.Minor})" + ); + diagnostics.Add( + (Severity.Hint, // Temporary downgrade to Hint until the currently available docs are adjusted + $"Key '{key}': Invalid range(s) where first version is greater than last version: {string.Join(", ", rangeDescriptions)}.") + ); } // Rule: No overlapping version ranges - var versionedItems = items - .Where(a => a.Version is not null && a.Version != AllVersionsSpec.Instance) - .ToList(); + var versionedItems = items.Where(a => a.Version is not null && a.Version != AllVersionsSpec.Instance).ToList(); var hasOverlaps = false; for (var i = 0; i < versionedItems.Count && !hasOverlaps; i++) @@ -419,19 +454,29 @@ private static void ValidateApplicabilityCollection(string key, AppliesCollectio if (hasOverlaps) { - diagnostics.Add((Severity.Hint, // Temporary downgrade to Hint until the currently available docs are adjusted - $"Key '{key}': Overlapping version ranges detected. Ensure version ranges do not overlap within the same key.")); + diagnostics.Add( + (Severity.Hint, // Temporary downgrade to Hint until the currently available docs are adjusted + $"Key '{key}': Overlapping version ranges detected. Ensure version ranges do not overlap within the same key.") + ); } } private static bool CheckVersionOverlap(VersionSpec v1, VersionSpec v2) { // Allow overlap in case there is a version bump - if (v1.Kind == VersionSpecKind.Range && v2.Kind == VersionSpecKind.GreaterThanOrEqual && - v1.Max is not null && v1.Max.CompareTo(v2.Min) <= 0) + if ( + v1.Kind == VersionSpecKind.Range + && v2.Kind == VersionSpecKind.GreaterThanOrEqual + && v1.Max is not null + && v1.Max.CompareTo(v2.Min) <= 0 + ) return false; - if (v2.Kind == VersionSpecKind.Range && v1.Kind == VersionSpecKind.GreaterThanOrEqual && - v2.Max is not null && v2.Max.CompareTo(v1.Min) <= 0) + if ( + v2.Kind == VersionSpecKind.Range + && v1.Kind == VersionSpecKind.GreaterThanOrEqual + && v2.Max is not null + && v2.Max.CompareTo(v1.Min) <= 0 + ) return false; // Get the effective ranges for each version spec @@ -442,8 +487,7 @@ private static bool CheckVersionOverlap(VersionSpec v1, VersionSpec v2) var (v1Min, v1Max) = GetEffectiveRange(v1); var (v2Min, v2Max) = GetEffectiveRange(v2); - return v1Min.CompareTo(v2Max ?? AllVersions.Instance) <= 0 && - v2Min.CompareTo(v1Max ?? AllVersions.Instance) <= 0; + return v1Min.CompareTo(v2Max ?? AllVersions.Instance) <= 0 && v2Min.CompareTo(v1Max ?? AllVersions.Instance) <= 0; } private static (SemVersion min, SemVersion? max) GetEffectiveRange(VersionSpec spec) => spec.Kind switch @@ -454,6 +498,5 @@ private static bool CheckVersionOverlap(VersionSpec v1, VersionSpec v2) _ => throw new ArgumentOutOfRangeException(nameof(spec), spec.Kind, "Unknown VersionSpecKind") }; - public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => - serializer.Invoke(value, type); + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => serializer.Invoke(value, type); } diff --git a/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs b/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs index b112efba7a..621889d0ff 100644 --- a/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs +++ b/src/Elastic.Documentation.Configuration/ElasticsearchEndpointConfigurator.cs @@ -113,7 +113,8 @@ public static async Task ApplyAsync( ElasticsearchIndexOptions options, IDiagnosticsCollector collector, IFileSystem fileSystem, - Cancel ctx) + Cancel ctx + ) { if (options.Endpoint is not null) cfg.Uri = options.Endpoint; diff --git a/src/Elastic.Documentation.Configuration/EnvironmentInterpolation.cs b/src/Elastic.Documentation.Configuration/EnvironmentInterpolation.cs index f9e9f1f994..b37f22f3aa 100644 --- a/src/Elastic.Documentation.Configuration/EnvironmentInterpolation.cs +++ b/src/Elastic.Documentation.Configuration/EnvironmentInterpolation.cs @@ -28,8 +28,10 @@ public sealed record InterpolatedValue(string? Value, string? Fallback); public static partial class EnvironmentInterpolation { /// Environment variable names that may be interpolated into committed config values. - public static readonly FrozenSet AllowedVariables = - new HashSet(StringComparer.Ordinal) { "KIBANA_STORYBOOK_REGISTRY" }.ToFrozenSet(StringComparer.Ordinal); + public static readonly FrozenSet AllowedVariables = new HashSet(StringComparer.Ordinal) + { + "KIBANA_STORYBOOK_REGISTRY" + }.ToFrozenSet(StringComparer.Ordinal); [GeneratedRegex(@"\$\{(?[A-Za-z_][A-Za-z0-9_]*)(?::-(?[^}]*))?\}", RegexOptions.CultureInvariant)] private static partial Regex ExpressionRegex(); diff --git a/src/Elastic.Documentation.Configuration/Inference/DocumentInference.cs b/src/Elastic.Documentation.Configuration/Inference/DocumentInference.cs index 38107235f8..70773be0f5 100644 --- a/src/Elastic.Documentation.Configuration/Inference/DocumentInference.cs +++ b/src/Elastic.Documentation.Configuration/Inference/DocumentInference.cs @@ -60,7 +60,8 @@ DocumentInferenceResult InferForMarkdown( IReadOnlyCollection? mappedPages, HashSet docsetProducts, IReadOnlyCollection? frontmatterProducts, - ApplicableTo? applicableTo); + ApplicableTo? applicableTo + ); /// /// Infers product, version system, and repository for an OpenAPI endpoint. @@ -80,9 +81,13 @@ public class DocumentInferrerService( LegacyUrlMappingConfiguration legacyUrlMappings, ConfigurationFile? configurationFile = null, GitCheckoutInformation? gitCheckout = null, - AssemblyConfiguration? assemblyConfiguration = null) : IDocumentInferrerService + AssemblyConfiguration? assemblyConfiguration = null +) : IDocumentInferrerService { - private readonly IVersionInferrerService _versionInferrer = new ProductVersionInferrerService(productsConfiguration, versionsConfiguration); + private readonly IVersionInferrerService _versionInferrer = new ProductVersionInferrerService( + productsConfiguration, + versionsConfiguration + ); private readonly ProductInferService _productInferService = new(productsConfiguration, gitCheckout); public ConfigurationFile? ConfigurationFile => configurationFile; @@ -94,7 +99,8 @@ public DocumentInferenceResult InferForMarkdown( IReadOnlyCollection? mappedPages, HashSet docsetProducts, IReadOnlyCollection? frontmatterProducts, - ApplicableTo? applicableTo) + ApplicableTo? applicableTo + ) { var relatedProducts = new HashSet(); @@ -149,8 +155,7 @@ public DocumentInferenceResult InferForOpenApi(string productSlug) var productId = productSlug.ToLowerInvariant(); var product = productsConfiguration.Products.GetValueOrDefault(productId); - var versioningSystem = product?.VersioningSystem - ?? versionsConfiguration.VersioningSystems[VersioningSystemId.Stack]; + var versioningSystem = product?.VersioningSystem ?? versionsConfiguration.VersioningSystems[VersioningSystemId.Stack]; // For OpenAPI, the product is always known var relatedProducts = new List(); @@ -162,6 +167,7 @@ public DocumentInferenceResult InferForOpenApi(string productSlug) Product = product, ProductVersion = versioningSystem.IsVersionless ? null : versioningSystem.Current.ToString(), Repository = productId, // For OpenAPI, repository matches product slug + RelatedProducts = relatedProducts }; } @@ -177,8 +183,9 @@ public DocumentInferenceResult InferForOpenApi(string productSlug) var mappedPage = mappedPages.First(); // Find matching legacy URL mapping by BaseUrl - var legacyMapping = legacyUrlMappings.Mappings - .FirstOrDefault(x => mappedPage.Contains(x.BaseUrl, StringComparison.OrdinalIgnoreCase)); + var legacyMapping = legacyUrlMappings.Mappings.FirstOrDefault( + x => mappedPage.Contains(x.BaseUrl, StringComparison.OrdinalIgnoreCase) + ); return legacyMapping?.Product; } @@ -209,8 +216,9 @@ public DocumentInferenceResult InferForOpenApi(string productSlug) var mappedPage = mappedPages.First(); // Find matching legacy URL mapping - var legacyMapping = legacyUrlMappings.Mappings - .FirstOrDefault(x => mappedPage.Contains(x.BaseUrl, StringComparison.OrdinalIgnoreCase)); + var legacyMapping = legacyUrlMappings.Mappings.FirstOrDefault( + x => mappedPage.Contains(x.BaseUrl, StringComparison.OrdinalIgnoreCase) + ); if (legacyMapping is null) return null; @@ -244,7 +252,8 @@ public DocumentInferenceResult InferForMarkdown( IReadOnlyCollection? mappedPages, HashSet docsetProducts, IReadOnlyCollection? frontmatterProducts, - ApplicableTo? applicableTo) => new(); + ApplicableTo? applicableTo + ) => new(); public DocumentInferenceResult InferForOpenApi(string productSlug) => new(); } diff --git a/src/Elastic.Documentation.Configuration/Inference/ProductInferService.cs b/src/Elastic.Documentation.Configuration/Inference/ProductInferService.cs index 6d4f5c2b2f..577541aedb 100644 --- a/src/Elastic.Documentation.Configuration/Inference/ProductInferService.cs +++ b/src/Elastic.Documentation.Configuration/Inference/ProductInferService.cs @@ -10,9 +10,7 @@ namespace Elastic.Documentation.Configuration.Inference; /// /// Service for inferring products from repository names and git context. /// -public class ProductInferService( - ProductsConfiguration productsConfiguration, - GitCheckoutInformation? gitCheckout = null) +public class ProductInferService(ProductsConfiguration productsConfiguration, GitCheckoutInformation? gitCheckout = null) { /// /// Infers a product from repository name. @@ -34,9 +32,7 @@ public class ProductInferService( /// Returns null if not available (no filesystem fallback). /// public string? GetRepositoryName() => - gitCheckout is not null && gitCheckout != GitCheckoutInformation.Unavailable - ? gitCheckout.RepositoryName - : null; + gitCheckout is not null && gitCheckout != GitCheckoutInformation.Unavailable ? gitCheckout.RepositoryName : null; /// /// Convenience method: infers product from current git repository. diff --git a/src/Elastic.Documentation.Configuration/Inference/VersionInference.cs b/src/Elastic.Documentation.Configuration/Inference/VersionInference.cs index c7d176f59a..81dd29f5a7 100644 --- a/src/Elastic.Documentation.Configuration/Inference/VersionInference.cs +++ b/src/Elastic.Documentation.Configuration/Inference/VersionInference.cs @@ -12,31 +12,51 @@ namespace Elastic.Documentation.Configuration.Inference; public interface IVersionInferrerService { - VersioningSystem InferVersion(string repositoryName, IReadOnlyCollection? legacyPages, IReadOnlyCollection? products, ApplicableTo? applicableTo); + VersioningSystem InferVersion( + string repositoryName, + IReadOnlyCollection? legacyPages, + IReadOnlyCollection? products, + ApplicableTo? applicableTo + ); } -public class ProductVersionInferrerService(ProductsConfiguration productsConfiguration, VersionsConfiguration versionsConfiguration) : IVersionInferrerService +public class ProductVersionInferrerService( + ProductsConfiguration productsConfiguration, + VersionsConfiguration versionsConfiguration +) : IVersionInferrerService { private ProductsConfiguration ProductsConfiguration { get; } = productsConfiguration; private VersionsConfiguration VersionsConfiguration { get; } = versionsConfiguration; - public VersioningSystem InferVersion(string repositoryName, IReadOnlyCollection? legacyPages, IReadOnlyCollection? products, ApplicableTo? applicableTo) + public VersioningSystem InferVersion( + string repositoryName, + IReadOnlyCollection? legacyPages, + IReadOnlyCollection? products, + ApplicableTo? applicableTo + ) { if (legacyPages is { Count: > 0 }) - return legacyPages.ElementAt(0).Product.VersioningSystem!; // If the page has legacy mappings, use the versioning system of the first mapping's product + return legacyPages.ElementAt(0) + .Product + .VersioningSystem!; // If the page has legacy mappings, use the versioning system of the first mapping's product if (applicableTo is not null) { - var versioningFromApplicability = VersioningFromApplicability(applicableTo); // Try to infer the versioning system from the applicability metadata + var versioningFromApplicability = VersioningFromApplicability( + applicableTo + ); // Try to infer the versioning system from the applicability metadata if (versioningFromApplicability is not null) return versioningFromApplicability; } var versioning = ProductsConfiguration.Products.TryGetValue(repositoryName, out var belonging) - ? belonging.VersioningSystem! //If the page's docset has a name with a direct product match, use the versioning system of the product - : ProductsConfiguration.Products.Values.SingleOrDefault(p => - p.Repository is not null && p.Repository.Equals(repositoryName, StringComparison.OrdinalIgnoreCase)) is { } repositoryMatch - ? repositoryMatch.VersioningSystem! // Verify if the page belongs to a repository linked to a product, and if so, use the versioning system of the product - : VersionsConfiguration.VersioningSystems[VersioningSystemId.Stack]; // Fallback to the stack versioning system + ? belonging.VersioningSystem! //If the page's docset has a name with a direct product match, use the versioning system of the product + + : ProductsConfiguration.Products + .Values + .SingleOrDefault(p => p.Repository is not null && p.Repository.Equals(repositoryName, StringComparison.OrdinalIgnoreCase)) is { } repositoryMatch + ? repositoryMatch.VersioningSystem! // Verify if the page belongs to a repository linked to a product, and if so, use the versioning system of the product + + : VersionsConfiguration.VersioningSystems[VersioningSystemId.Stack]; // Fallback to the stack versioning system return versioning; } @@ -95,10 +115,10 @@ public VersioningSystem InferVersion(string repositoryName, IReadOnlyCollection< public class NoopVersionInferrer : IVersionInferrerService { - public VersioningSystem InferVersion(string repositoryName, IReadOnlyCollection? legacyPages, IReadOnlyCollection? products, ApplicableTo? applicableTo) => new() - { - Id = VersioningSystemId.Stack, - Base = ZeroVersion.Instance, - Current = ZeroVersion.Instance - }; + public VersioningSystem InferVersion( + string repositoryName, + IReadOnlyCollection? legacyPages, + IReadOnlyCollection? products, + ApplicableTo? applicableTo + ) => new() { Id = VersioningSystemId.Stack, Base = ZeroVersion.Instance, Current = ZeroVersion.Instance }; } diff --git a/src/Elastic.Documentation.Configuration/LegacyUrlMappings/LegacyUrlMappingExtensions.cs b/src/Elastic.Documentation.Configuration/LegacyUrlMappings/LegacyUrlMappingExtensions.cs index 07760d8ed7..7349b142ac 100644 --- a/src/Elastic.Documentation.Configuration/LegacyUrlMappings/LegacyUrlMappingExtensions.cs +++ b/src/Elastic.Documentation.Configuration/LegacyUrlMappings/LegacyUrlMappingExtensions.cs @@ -9,19 +9,26 @@ namespace Elastic.Documentation.Configuration.LegacyUrlMappings; public static class LegacyUrlMappingExtensions { - public static LegacyUrlMappingConfiguration CreateLegacyUrlMappings(this ConfigurationFileProvider provider, ProductsConfiguration products) + public static LegacyUrlMappingConfiguration CreateLegacyUrlMappings( + this ConfigurationFileProvider provider, + ProductsConfiguration products + ) { var legacyUrlMappingsFilePath = provider.LegacyUrlMappingsFile; - var legacyUrlMappingsDto = ConfigurationFileProvider.Deserializer.Deserialize(legacyUrlMappingsFilePath.OpenText()); - - var legacyUrlMappings = legacyUrlMappingsDto.Mappings.Select(kvp => - new LegacyUrlMapping - { - BaseUrl = kvp.Key, - Product = products.Products[kvp.Value.Product], - LegacyVersions = kvp.Value.LegacyVersions.ToImmutableList() - }); + var legacyUrlMappingsDto = ConfigurationFileProvider.Deserializer.Deserialize( + legacyUrlMappingsFilePath.OpenText() + ); + + var legacyUrlMappings = legacyUrlMappingsDto.Mappings.Select( + kvp => + new LegacyUrlMapping + { + BaseUrl = kvp.Key, + Product = products.Products[kvp.Value.Product], + LegacyVersions = kvp.Value.LegacyVersions.ToImmutableList() + } + ); return new LegacyUrlMappingConfiguration { Mappings = legacyUrlMappings.ToImmutableList() }; } diff --git a/src/Elastic.Documentation.Configuration/Products/Product.cs b/src/Elastic.Documentation.Configuration/Products/Product.cs index b05ece7adc..661ff855ce 100644 --- a/src/Elastic.Documentation.Configuration/Products/Product.cs +++ b/src/Elastic.Documentation.Configuration/Products/Product.cs @@ -39,7 +39,9 @@ public record ProductsConfiguration : IProductNameLookup var repositoryName = tokens.Last(); if (Products.TryGetValue(repositoryName, out var product)) return product; - var match = Products.Values.SingleOrDefault(p => p.Repository is not null && p.Repository.Equals(repositoryName, StringComparison.OrdinalIgnoreCase)); + var match = Products.Values.SingleOrDefault( + p => p.Repository is not null && p.Repository.Equals(repositoryName, StringComparison.OrdinalIgnoreCase) + ); return match; } @@ -86,7 +88,10 @@ public record ProductFeatures /// All features enabled -- the implicit default when no features map is present in YAML. public static ProductFeatures All => new() { PublicReference = true, ReleaseNotes = true }; - public static readonly FrozenSet KnownKeys = FrozenSet.ToFrozenSet(["public-reference", "release-notes"], StringComparer.OrdinalIgnoreCase); + public static readonly FrozenSet KnownKeys = FrozenSet.ToFrozenSet( + ["public-reference", "release-notes"], + StringComparer.OrdinalIgnoreCase + ); } [YamlSerializable] @@ -98,4 +103,3 @@ public record Product public string? Repository { get; init; } public ProductFeatures Features { get; init; } = ProductFeatures.All; } - diff --git a/src/Elastic.Documentation.Configuration/Products/ProductExtensions.cs b/src/Elastic.Documentation.Configuration/Products/ProductExtensions.cs index 0c825afb2d..ab6a2984f2 100644 --- a/src/Elastic.Documentation.Configuration/Products/ProductExtensions.cs +++ b/src/Elastic.Documentation.Configuration/Products/ProductExtensions.cs @@ -26,7 +26,8 @@ public static ProductsConfiguration CreateProducts(this ConfigurationFileProvide versioningSystem ??= !features.PublicReference ? VersioningSystem.None : throw new InvalidOperationException( - $"Product '{kvp.Key}' has invalid or missing versioning '{kvp.Value.Versioning ?? kvp.Key}' while 'public-reference' is enabled."); + $"Product '{kvp.Key}' has invalid or missing versioning '{kvp.Value.Versioning ?? kvp.Key}' while 'public-reference' is enabled." + ); return new Product { @@ -36,15 +37,15 @@ public static ProductsConfiguration CreateProducts(this ConfigurationFileProvide Repository = kvp.Value.Repository ?? kvp.Key, Features = features }; - }); + } + ); - var publicReferenceProducts = products - .Where(kvp => kvp.Value.Features.PublicReference) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - - var productDisplayNames = productsDto.Products.ToDictionary( + var publicReferenceProducts = products.Where(kvp => kvp.Value.Features.PublicReference).ToDictionary( kvp => kvp.Key, - kvp => kvp.Value.Display); + kvp => kvp.Value + ); + + var productDisplayNames = productsDto.Products.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Display); return new ProductsConfiguration { @@ -64,9 +65,7 @@ private static ProductFeatures ResolveFeatures(string productId, Dictionary !ProductFeatures.KnownKeys.Contains(k)) - .ToList(); + var unknownKeys = featuresDto.Keys.Where(k => !ProductFeatures.KnownKeys.Contains(k)).ToList(); if (unknownKeys is { Count: > 0 }) { diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleAmendMerger.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleAmendMerger.cs index 29e381758c..32d55d7d5c 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleAmendMerger.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleAmendMerger.cs @@ -36,17 +36,13 @@ public static int GetAmendFileNumber(string filePath) public static string? GetParentBundlePath(string filePath) { var match = AmendFileRegex().Match(filePath); - return match.Success - ? string.Concat(filePath.AsSpan(0, match.Index), match.Groups[2].Value) - : null; + return match.Success ? string.Concat(filePath.AsSpan(0, match.Index), match.Groups[2].Value) : null; } /// /// Applies amend bundles in order to parent entries and returns the effective entry list. /// - public static List MergeEntries( - IReadOnlyList parentEntries, - IReadOnlyList amendBundlesInOrder) + public static List MergeEntries(IReadOnlyList parentEntries, IReadOnlyList amendBundlesInOrder) { var current = parentEntries.ToList(); foreach (var amend in amendBundlesInOrder) @@ -57,8 +53,7 @@ public static List MergeEntries( /// /// Collects all exclusion keys already applied by prior amend files. /// - public static HashSet CollectAppliedExclusionKeys( - IReadOnlyList amendBundlesInOrder) + public static HashSet CollectAppliedExclusionKeys(IReadOnlyList amendBundlesInOrder) { var keys = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var amend in amendBundlesInOrder) @@ -102,16 +97,12 @@ private static List ApplySingleAmend(IReadOnlyList e return result; } - private static List ApplyExclusions( - IReadOnlyList entries, - IReadOnlyList exclusions) + private static List ApplyExclusions(IReadOnlyList entries, IReadOnlyList exclusions) { if (exclusions.Count == 0) return entries.ToList(); - return entries - .Where(entry => !exclusions.Any(exclusion => EntryMatchesExclusion(entry, exclusion))) - .ToList(); + return entries.Where(entry => !exclusions.Any(exclusion => EntryMatchesExclusion(entry, exclusion))).ToList(); } private static string? NormalizeFileName(string? fileName) diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs index 6596db8f1f..49e85f7851 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs @@ -21,9 +21,7 @@ public partial class BundleLoader(IFileSystem fileSystem) /// The absolute path to the bundles folder. /// Callback to emit warnings during loading. /// A list of successfully loaded bundles. - public IReadOnlyList LoadBundles( - string bundlesFolderPath, - Action emitWarning) + public IReadOnlyList LoadBundles(string bundlesFolderPath, Action emitWarning) { var yamlFiles = fileSystem.Directory .EnumerateFiles(bundlesFolderPath, "*.yaml") @@ -62,7 +60,8 @@ public IReadOnlyList LoadBundles( /// A list of successfully loaded bundles. public IReadOnlyList LoadBundlesFromContent( IReadOnlyList<(string FileName, string Content)> bundles, - Action emitWarning) + Action emitWarning + ) { var loadedBundles = new List(bundles.Count); @@ -99,10 +98,7 @@ public IReadOnlyList LoadBundlesFromContent( /// The bundle file name, used in diagnostics. /// Callback to emit warnings during resolution. /// A list of resolved changelog entries. - public static List ResolveEntries( - Bundle bundledData, - string bundleName, - Action emitWarning) + public static List ResolveEntries(Bundle bundledData, string bundleName, Action emitWarning) { var entries = new List(bundledData.Entries.Count); @@ -116,7 +112,8 @@ public static List ResolveEntries( var entryName = !string.IsNullOrWhiteSpace(entry.File?.Name) ? entry.File.Name : entry.Title ?? ""; emitWarning( - $"Bundle '{bundleName}' entry '{entryName}' has no inline content (title and type are required); bundles must inline their entries. Skipping."); + $"Bundle '{bundleName}' entry '{entryName}' has no inline content (title and type are required); bundles must inline their entries. Skipping." + ); } return entries; @@ -129,9 +126,7 @@ public static List ResolveEntries( /// The entries to filter. /// Optional publish blocker configuration. /// Filtered list of entries. - public IReadOnlyList FilterEntries( - IReadOnlyList entries, - PublishBlocker? publishBlocker) + public IReadOnlyList FilterEntries(IReadOnlyList entries, PublishBlocker? publishBlocker) { if (publishBlocker is not { HasBlockingRules: true }) return entries; @@ -149,11 +144,7 @@ public static IReadOnlyList MergeBundlesByTarget(IReadOnlyList b.Version) - .Select(MergeBundleGroup) - .OrderByDescending(b => VersionOrDate.Parse(b.Version)) - .ToList(); + return bundles.GroupBy(b => b.Version).Select(MergeBundleGroup).OrderByDescending(b => VersionOrDate.Parse(b.Version)).ToList(); } /// @@ -191,9 +182,7 @@ private static string GetRepoFromBundle(Bundle bundledData) var firstProduct = bundledData.Products[0]; // Use explicit Repo if provided, otherwise fall back to ProductId - return !string.IsNullOrWhiteSpace(firstProduct.Repo) - ? firstProduct.Repo - : firstProduct.ProductId; + return !string.IsNullOrWhiteSpace(firstProduct.Repo) ? firstProduct.Repo : firstProduct.ProductId; } /// @@ -206,9 +195,7 @@ private static string GetOwnerFromBundle(Bundle bundledData) return "elastic"; var firstProduct = bundledData.Products[0]; - return !string.IsNullOrWhiteSpace(firstProduct.Owner) - ? firstProduct.Owner - : "elastic"; + return !string.IsNullOrWhiteSpace(firstProduct.Owner) ? firstProduct.Owner : "elastic"; } /// @@ -230,10 +217,7 @@ private static LoadedBundle MergeBundleGroup(IGrouping gro // Use the first bundle's metadata as the base var first = bundlesList[0]; - var descriptions = bundlesList - .Select(b => b.Data?.Description) - .Where(d => !string.IsNullOrEmpty(d)) - .ToList(); + var descriptions = bundlesList.Select(b => b.Data?.Description).Where(d => !string.IsNullOrEmpty(d)).ToList(); var mergedDescription = descriptions.Count switch { @@ -242,12 +226,7 @@ private static LoadedBundle MergeBundleGroup(IGrouping gro _ => string.Join("\n\n", descriptions) }; - var releaseDates = bundlesList - .Select(b => b.Data?.ReleaseDate) - .Where(d => d.HasValue) - .Select(d => d!.Value) - .Distinct() - .ToList(); + var releaseDates = bundlesList.Select(b => b.Data?.ReleaseDate).Where(d => d.HasValue).Select(d => d!.Value).Distinct().ToList(); var mergedReleaseDate = releaseDates.Count switch { @@ -259,14 +238,7 @@ private static LoadedBundle MergeBundleGroup(IGrouping gro ? first.Data with { Description = mergedDescription, ReleaseDate = mergedReleaseDate } : new Bundle { Description = mergedDescription, ReleaseDate = mergedReleaseDate }; - return new LoadedBundle( - first.Version, - combinedRepo, - first.Owner, - mergedData, - first.FilePath, - mergedEntries - ); + return new LoadedBundle(first.Version, combinedRepo, first.Owner, mergedData, first.FilePath, mergedEntries); } /// @@ -276,9 +248,7 @@ private static LoadedBundle MergeBundleGroup(IGrouping gro /// The list of loaded bundles including amend files. /// Callback to emit warnings during entry resolution. /// A list of bundles with amend file entries merged into their parent bundles. - private List MergeAmendFiles( - List bundles, - Action emitWarning) + private List MergeAmendFiles(List bundles, Action emitWarning) { if (bundles.Count <= 1) return bundles; @@ -292,9 +262,7 @@ private List MergeAmendFiles( var mergedAmendPaths = new HashSet(StringComparer.OrdinalIgnoreCase); var mergedParents = new Dictionary(StringComparer.OrdinalIgnoreCase); - var amendsByParent = amendBundles - .GroupBy(a => GetParentBundlePath(a.FilePath)) - .Where(group => group.Key != null); + var amendsByParent = amendBundles.GroupBy(a => GetParentBundlePath(a.FilePath)).Where(group => group.Key != null); foreach (var group in amendsByParent) { @@ -302,33 +270,21 @@ private List MergeAmendFiles( if (!bundlesByPath.TryGetValue(parentPath, out var parentBundle)) continue; - var orderedAmendData = group - .OrderBy(a => BundleAmendMerger.GetAmendFileNumber(a.FilePath)) - .Select(a => a.Data) - .ToList(); + var orderedAmendData = group.OrderBy(a => BundleAmendMerger.GetAmendFileNumber(a.FilePath)).Select(a => a.Data).ToList(); var mergedEntryList = BundleAmendMerger.MergeEntries(parentBundle.Data.Entries, orderedAmendData); var mergedBundleData = parentBundle.Data with { Entries = mergedEntryList }; var resolvedEntries = ResolveEntries(mergedBundleData, fileSystem.Path.GetFileName(parentPath), emitWarning); - mergedParents[parentPath] = new LoadedBundle( - parentBundle.Version, - parentBundle.Repo, - parentBundle.Owner, - mergedBundleData, - parentPath, - resolvedEntries); + mergedParents[parentPath] = + new LoadedBundle(parentBundle.Version, parentBundle.Repo, parentBundle.Owner, mergedBundleData, parentPath, resolvedEntries); foreach (var amend in group) _ = mergedAmendPaths.Add(amend.FilePath); } - return bundles - .Where(bundle => !mergedAmendPaths.Contains(bundle.FilePath)) - .Select(bundle => - mergedParents.TryGetValue(bundle.FilePath, out var mergedBundle) - ? mergedBundle - : bundle) + return bundles.Where(bundle => !mergedAmendPaths.Contains(bundle.FilePath)) + .Select(bundle => mergedParents.TryGetValue(bundle.FilePath, out var mergedBundle) ? mergedBundle : bundle) .ToList(); } @@ -352,5 +308,4 @@ private List MergeAmendFiles( [GeneratedRegex(@"\.amend-\d+\.ya?ml$", RegexOptions.IgnoreCase)] private static partial Regex AmendFileRegex(); - } diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogEntryFetcher.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogEntryFetcher.cs index c5eb895480..88f53a1614 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogEntryFetcher.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogEntryFetcher.cs @@ -51,12 +51,11 @@ public sealed class CdnChangelogEntryFetcher : IDisposable /// leaking a socket handle per fetch, and /// bounds DNS staleness. It is intentionally never disposed — it lives for the lifetime of the process. /// - private static readonly HttpClient SharedHttpClient = new( - new SocketsHttpHandler - { - AutomaticDecompression = DecompressionMethods.All, - PooledConnectionLifetime = TimeSpan.FromMinutes(5) - }) + private static readonly HttpClient SharedHttpClient = new(new SocketsHttpHandler + { + AutomaticDecompression = DecompressionMethods.All, + PooledConnectionLifetime = TimeSpan.FromMinutes(5) + }) { Timeout = FetchTimeout }; private readonly ILogger _logger; @@ -75,7 +74,8 @@ public CdnChangelogEntryFetcher( ILoggerFactory logFactory, HttpMessageHandler? handler = null, int maxAttempts = DefaultMaxAttempts, - Func? sleep = null) + Func? sleep = null + ) { _logger = logFactory.CreateLogger(); _maxAttempts = maxAttempts < 1 ? DefaultMaxAttempts : maxAttempts; @@ -104,7 +104,8 @@ public async Task> FetchAsync( string branch, Action emitError, Action emitWarning, - Cancel ctx) + Cancel ctx + ) { var poolLabel = $"{org}/{repo}/{branch}"; @@ -114,7 +115,8 @@ public async Task> FetchAsync( if (!ChangelogKeys.IsValidOrg(org) || !ChangelogKeys.IsValidRepo(repo) || !ChangelogKeys.IsValidBranch(branch)) { emitError( - $"Invalid changelog pool '{poolLabel}': the org, repo, and each '/'-delimited branch segment must be non-empty ASCII letters, digits, '.', '_' or '-' (org allows only letters, digits and '-') and must not be '.' or '..'."); + $"Invalid changelog pool '{poolLabel}': the org, repo, and each '/'-delimited branch segment must be non-empty ASCII letters, digits, '.', '_' or '-' (org allows only letters, digits and '-') and must not be '.' or '..'." + ); return []; } @@ -141,7 +143,8 @@ public async Task> FetchAsync( if (registry.SchemaVersion > SupportedSchemaVersion) { emitError( - $"Changelog entry registry for '{poolLabel}' uses schema version {registry.SchemaVersion}, but this build only understands version {SupportedSchemaVersion}. Update docs-builder."); + $"Changelog entry registry for '{poolLabel}' uses schema version {registry.SchemaVersion}, but this build only understands version {SupportedSchemaVersion}. Update docs-builder." + ); return []; } @@ -170,7 +173,8 @@ public async Task> FetchAsync( // a genuine propagation/scrub failure — fail rather than ship a bundle missing this entry. emitError( $"Changelog entry '{fileName}' for '{poolLabel}' is listed in the registry but could not be fetched from {entryUri} after {_maxAttempts} attempt(s): {lastError}. " + - "The scrubbed copy may not have propagated to the CDN yet; retry shortly, and if it persists check the changelog scrubber pipeline."); + "The scrubbed copy may not have propagated to the CDN yet; retry shortly, and if it persists check the changelog scrubber pipeline." + ); return []; } @@ -183,7 +187,12 @@ public async Task> FetchAsync( /// up to times with exponential backoff. Retry requests are cache-busted /// so a CloudFront-cached 404 cannot pin the result for the whole window. /// - private async Task<(bool Fetched, string Content, string? LastError)> TryFetchEntryAsync(Uri uri, string fileName, string poolLabel, Cancel ctx) + private async Task<(bool Fetched, string Content, string? LastError)> TryFetchEntryAsync( + Uri uri, + string fileName, + string poolLabel, + Cancel ctx + ) { string? lastError = null; @@ -194,7 +203,13 @@ public async Task> FetchAsync( { var content = await FetchTextAsync(uri, attempt, ctx).ConfigureAwait(false); if (attempt > 1) - _logger.LogInformation("Fetched changelog entry '{File}' for {Pool} on attempt {Attempt}/{Max}", fileName, poolLabel, attempt, _maxAttempts); + _logger.LogInformation( + "Fetched changelog entry '{File}' for {Pool} on attempt {Attempt}/{Max}", + fileName, + poolLabel, + attempt, + _maxAttempts + ); return (true, content, null); } catch (Exception ex) when (ex is not OperationCanceledException) @@ -206,7 +221,13 @@ public async Task> FetchAsync( var delay = RetryDelay(attempt); _logger.LogDebug( "Changelog entry '{File}' for {Pool} not yet available (attempt {Attempt}/{Max}: {Error}); retrying in {Delay}", - fileName, poolLabel, attempt, _maxAttempts, ex.Message, delay); + fileName, + poolLabel, + attempt, + _maxAttempts, + ex.Message, + delay + ); await _sleep(delay, ctx).ConfigureAwait(false); } } @@ -221,7 +242,9 @@ public async Task> FetchAsync( using var response = await _httpClient.SendAsync(request, ctx).ConfigureAwait(false); _ = response.EnsureSuccessStatusCode(); await using var stream = await response.Content.ReadAsStreamAsync(ctx).ConfigureAwait(false); - return await JsonSerializer.DeserializeAsync(stream, ChangelogRegistryJsonContext.Default.ChangelogRegistry, ctx).ConfigureAwait(false); + return await JsonSerializer.DeserializeAsync(stream, ChangelogRegistryJsonContext.Default.ChangelogRegistry, ctx).ConfigureAwait( + false + ); } private async Task FetchTextAsync(Uri uri, int attempt, Cancel ctx) diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogFetcher.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogFetcher.cs index 08cda2c97f..885b8f0390 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogFetcher.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogFetcher.cs @@ -46,12 +46,11 @@ public sealed class CdnChangelogFetcher : IDisposable /// bounds DNS staleness in long-lived serve/watch runs. It is intentionally never disposed — it /// lives for the lifetime of the process. /// - private static readonly HttpClient SharedHttpClient = new( - new SocketsHttpHandler - { - AutomaticDecompression = DecompressionMethods.All, - PooledConnectionLifetime = TimeSpan.FromMinutes(5) - }) + private static readonly HttpClient SharedHttpClient = new(new SocketsHttpHandler + { + AutomaticDecompression = DecompressionMethods.All, + PooledConnectionLifetime = TimeSpan.FromMinutes(5) + }) { Timeout = FetchTimeout }; private readonly ILogger _logger; @@ -96,7 +95,8 @@ public async Task> FetchAsync( string? version, Action emitError, Action emitWarning, - Cancel ctx) + Cancel ctx + ) { // Defense-in-depth mirroring the entry fetcher's pool validation: reject anything the producer // would have refused to upload before building the URI, so normalization (e.g. a ".." product) @@ -129,7 +129,8 @@ public async Task> FetchAsync( if (registry.SchemaVersion > SupportedSchemaVersion) { emitError( - $"Changelog registry for product '{product}' uses schema version {registry.SchemaVersion}, but this build only understands version {SupportedSchemaVersion}. Update docs-builder."); + $"Changelog registry for product '{product}' uses schema version {registry.SchemaVersion}, but this build only understands version {SupportedSchemaVersion}. Update docs-builder." + ); return []; } @@ -150,7 +151,9 @@ public async Task> FetchAsync( using var response = await _httpClient.SendAsync(request, ctx).ConfigureAwait(false); _ = response.EnsureSuccessStatusCode(); await using var stream = await response.Content.ReadAsStreamAsync(ctx).ConfigureAwait(false); - return await JsonSerializer.DeserializeAsync(stream, ChangelogRegistryJsonContext.Default.ChangelogRegistry, ctx).ConfigureAwait(false); + return await JsonSerializer.DeserializeAsync(stream, ChangelogRegistryJsonContext.Default.ChangelogRegistry, ctx).ConfigureAwait( + false + ); } private async Task> DownloadBundlesAsync( @@ -159,7 +162,8 @@ public async Task> FetchAsync( string? version, ChangelogRegistry registry, Action emitWarning, - Cancel ctx) + Cancel ctx + ) { var selected = SelectBundles(version, registry.Bundles); var tasks = new List>(selected.Count); @@ -185,7 +189,8 @@ public async Task> FetchAsync( string fileName, string? etag, Action emitWarning, - Cancel ctx) + Cancel ctx + ) { var cached = TryGetCachedBundle(product, fileName, etag); if (cached is not null) @@ -221,13 +226,9 @@ private static List SelectBundles(string? version, IRea if (string.IsNullOrWhiteSpace(version)) return [.. bundles]; - var selected = bundles - .Where(b => ChangelogVersionMatch.Matches(version, b.Target, b.File)) - .ToList(); + var selected = bundles.Where(b => ChangelogVersionMatch.Matches(version, b.Target, b.File)).ToList(); - var selectedFiles = new HashSet( - selected.Select(b => b.File).OfType(), - StringComparer.OrdinalIgnoreCase); + var selectedFiles = new HashSet(selected.Select(b => b.File).OfType(), StringComparer.OrdinalIgnoreCase); foreach (var bundle in bundles) { @@ -306,11 +307,9 @@ private void WriteCachedBundle(string product, string fileName, string? etag, st } } - private static string CacheKey(string product, string fileName, string etag) => - $"changelog-{product}-{fileName}-{etag}"; + private static string CacheKey(string product, string fileName, string etag) => $"changelog-{product}-{fileName}-{etag}"; - private static string CachePath(string cacheKey) => - Path.Join(Paths.ApplicationData.FullName, "changelog-bundles", cacheKey); + private static string CachePath(string cacheKey) => Path.Join(Paths.ApplicationData.FullName, "changelog-bundles", cacheKey); /// /// Disposes the per-instance created for an injected handler. The shared diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogCdn.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogCdn.cs index 0b654cfbc4..619e9b5b4d 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogCdn.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogCdn.cs @@ -30,8 +30,6 @@ public static class ChangelogCdn { var configured = Environment.GetEnvironmentVariable(BaseUrlEnvironmentVariable); var raw = string.IsNullOrWhiteSpace(configured) ? DefaultBaseUrl : configured; - return Uri.TryCreate(raw, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https" - ? uri - : null; + return Uri.TryCreate(raw, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https" ? uri : null; } } diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogKeys.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogKeys.cs index dbbe3e22d9..6599832667 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogKeys.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogKeys.cs @@ -44,16 +44,13 @@ private enum SegmentKind } /// True when is a valid bundle product segment ([a-zA-Z0-9_-]+). - public static bool IsValidProduct([NotNullWhen(true)] string? product) => - IsValidSegment(product, SegmentKind.Product); + public static bool IsValidProduct([NotNullWhen(true)] string? product) => IsValidSegment(product, SegmentKind.Product); /// True when is a valid GitHub owner segment ([a-zA-Z0-9-]+). - public static bool IsValidOrg([NotNullWhen(true)] string? org) => - IsValidSegment(org, SegmentKind.Org); + public static bool IsValidOrg([NotNullWhen(true)] string? org) => IsValidSegment(org, SegmentKind.Org); /// True when is a valid repository segment ([a-zA-Z0-9._-]+, not ./..). - public static bool IsValidRepo([NotNullWhen(true)] string? repo) => - IsValidSegment(repo, SegmentKind.RepoOrBranch); + public static bool IsValidRepo([NotNullWhen(true)] string? repo) => IsValidSegment(repo, SegmentKind.RepoOrBranch); /// /// True when every /-delimited part of is a valid repo-class segment. @@ -78,25 +75,22 @@ public static bool IsValidBranch([NotNullWhen(true)] string? branch) /// public static bool IsSafeFileName([NotNullWhen(true)] string? fileName) => !string.IsNullOrWhiteSpace(fileName) - && fileName is not ("." or "..") - && !fileName.Contains('/', StringComparison.Ordinal) - && !fileName.Contains('\\', StringComparison.Ordinal); + && fileName is not ("." or "..") + && !fileName.Contains('/', StringComparison.Ordinal) + && !fileName.Contains('\\', StringComparison.Ordinal); /// The artifact-root key of an uploaded bundle file: bundle/{product}/{file}. - public static string BundleFileKey(string product, string fileName) => - $"{BundlePrefix}{product}/{fileName}"; + public static string BundleFileKey(string product, string fileName) => $"{BundlePrefix}{product}/{fileName}"; /// The artifact-root key of an uploaded changelog entry: changelog/{org}/{repo}/{branch}/{file}. public static string ChangelogFileKey(string org, string repo, string branch, string fileName) => $"{ChangelogPrefix}{org}/{repo}/{branch}/{fileName}"; /// The bundle-index manifest key for a product group: bundle/{product}/registry.json. - public static string BundleRegistryKey(string productGroup) => - $"{BundlePrefix}{productGroup}/{RegistryFileName}"; + public static string BundleRegistryKey(string productGroup) => $"{BundlePrefix}{productGroup}/{RegistryFileName}"; /// The changelog-entry-index manifest key for an {org}/{repo}/{branch} group: changelog/{group}/registry.json. - public static string ChangelogRegistryKey(string poolGroup) => - $"{ChangelogPrefix}{poolGroup}/{RegistryFileName}"; + public static string ChangelogRegistryKey(string poolGroup) => $"{ChangelogPrefix}{poolGroup}/{RegistryFileName}"; /// /// Extracts the product group from a bundle/{product}/{file} key, or null when @@ -140,8 +134,7 @@ public static string ChangelogRegistryKey(string poolGroup) => } /// The CDN path segments of a product's bundle pool (["bundle", product]), for per-segment URI escaping. - public static IReadOnlyList BundleSegments(string product) => - ["bundle", product]; + public static IReadOnlyList BundleSegments(string product) => ["bundle", product]; /// /// The CDN path segments of an org/repo/branch changelog pool — changelog, org, repo, then each @@ -172,9 +165,7 @@ public static bool IsRegistry(string key) } private static bool IsBundleRegistry(string key) => - TryGetRegistryGroup(key, BundlePrefix, out var group) - && group.IndexOf('/') < 0 - && IsValidSegment(group, SegmentKind.Product); + TryGetRegistryGroup(key, BundlePrefix, out var group) && group.IndexOf('/') < 0 && IsValidSegment(group, SegmentKind.Product); private static bool IsChangelogRegistry(string key) => TryGetRegistryGroup(key, ChangelogPrefix, out var group) && IsValidChangelogGroup(group); @@ -218,8 +209,7 @@ private static bool TryGetRegistryGroup(string key, string prefix, out ReadOnlyS return true; } - private static bool IsValidSegment(string? segment, SegmentKind kind) => - segment is not null && IsValidSegment(segment.AsSpan(), kind); + private static bool IsValidSegment(string? segment, SegmentKind kind) => segment is not null && IsValidSegment(segment.AsSpan(), kind); private static bool IsValidSegment(ReadOnlySpan segment, SegmentKind kind) { @@ -246,5 +236,4 @@ private static bool IsValidSegmentChar(char c, SegmentKind kind) _ => false }; } - } diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesFetcher.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesFetcher.cs index 42a7be088f..2e21a39f93 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesFetcher.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesFetcher.cs @@ -40,11 +40,7 @@ public static async Task PrefetchAsync(BuildContext conte public async Task FetchAsync(IDiagnosticsCollector collector, IReadOnlyCollection products, Cancel ctx) { - var declared = products - .Where(p => !string.IsNullOrWhiteSpace(p)) - .Select(p => p.Trim()) - .Distinct(StringComparer.Ordinal) - .ToArray(); + var declared = products.Where(p => !string.IsNullOrWhiteSpace(p)).Select(p => p.Trim()).Distinct(StringComparer.Ordinal).ToArray(); if (declared.Length == 0) return FetchedReleaseNotes.Empty; @@ -54,8 +50,10 @@ public async Task FetchAsync(IDiagnosticsCollector collecto var baseUri = ChangelogCdn.ResolveBaseUri(); if (baseUri is null) { - collector.EmitError(string.Empty, - $"No valid changelog CDN base URL is configured. Set the {ChangelogCdn.BaseUrlEnvironmentVariable} environment variable to an absolute http(s) URL."); + collector.EmitError( + string.Empty, + $"No valid changelog CDN base URL is configured. Set the {ChangelogCdn.BaseUrlEnvironmentVariable} environment variable to an absolute http(s) URL." + ); return new FetchedReleaseNotes { BundlesByProduct = FrozenDictionary>.Empty, @@ -69,13 +67,15 @@ public async Task FetchAsync(IDiagnosticsCollector collecto var tasks = declared.Select(async product => { // version: null — prefetch the full set; each directive applies its own :version: filter later. - var bundles = await fetcher.FetchAsync( - baseUri, - product, - version: null, - msg => collector.EmitError(string.Empty, msg), - msg => collector.EmitWarning(string.Empty, msg), - ctx).ConfigureAwait(false); + var bundles = + await fetcher.FetchAsync( + baseUri, + product, + version: null, + msg => collector.EmitError(string.Empty, msg), + msg => collector.EmitWarning(string.Empty, msg), + ctx + ).ConfigureAwait(false); return (product, bundles); }); diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs index a322ebb272..eac9415990 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs @@ -27,18 +27,16 @@ public static partial class ReleaseNotesSerialization [GeneratedRegex(@"(\s+)version:", RegexOptions.Multiline)] public static partial Regex VersionToTargetRegex(); - private static readonly IDeserializer YamlDeserializer = - new StaticDeserializerBuilder(new YamlStaticContext()) - .WithNamingConvention(UnderscoredNamingConvention.Instance) - .Build(); - - private static readonly ISerializer YamlSerializer = - new StaticSerializerBuilder(new YamlStaticContext()) - .WithNamingConvention(UnderscoredNamingConvention.Instance) - .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull | DefaultValuesHandling.OmitEmptyCollections) - .WithQuotingNecessaryStrings() - .DisableAliases() - .Build(); + private static readonly IDeserializer YamlDeserializer = new StaticDeserializerBuilder(new YamlStaticContext()).WithNamingConvention( + UnderscoredNamingConvention.Instance + ).Build(); + + private static readonly ISerializer YamlSerializer = new StaticSerializerBuilder(new YamlStaticContext()) + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull | DefaultValuesHandling.OmitEmptyCollections) + .WithQuotingNecessaryStrings() + .DisableAliases() + .Build(); /// /// Gets the raw YAML deserializer for changelog entry DTOs. @@ -152,87 +150,84 @@ private static string ToYamlDoubleQuotedString(string s) #region Manual Mapping Methods - private static ChangelogEntry ToEntry(ChangelogEntryDto dto) => new() - { - Prs = dto.Prs ?? (dto.Pr != null ? [dto.Pr] : null), - Issues = dto.Issues, - Type = ParseEntryType(dto.Type), - Subtype = ParseEntrySubtype(dto.Subtype), - Products = dto.Products?.Select(ToProductReference).ToList(), - Areas = dto.Areas, - Title = dto.Title ?? "", - Description = dto.Description, - Impact = dto.Impact, - Action = dto.Action, - FeatureId = dto.FeatureId, - Highlight = dto.Highlight - }; + private static ChangelogEntry ToEntry(ChangelogEntryDto dto) => + new() + { + Prs = dto.Prs ?? (dto.Pr != null ? [dto.Pr] : null), + Issues = dto.Issues, + Type = ParseEntryType(dto.Type), + Subtype = ParseEntrySubtype(dto.Subtype), + Products = dto.Products?.Select(ToProductReference).ToList(), + Areas = dto.Areas, + Title = dto.Title ?? "", + Description = dto.Description, + Impact = dto.Impact, + Action = dto.Action, + FeatureId = dto.FeatureId, + Highlight = dto.Highlight + }; - private static ChangelogEntry ToEntry(BundledEntry entry) => new() - { - Prs = entry.Prs, - Issues = entry.Issues, - Type = entry.Type ?? ChangelogEntryType.Invalid, - Subtype = entry.Subtype, - Products = entry.Products, - Areas = entry.Areas, - Title = entry.Title ?? "", - Description = entry.Description, - Impact = entry.Impact, - Action = entry.Action, - FeatureId = entry.FeatureId, - Highlight = entry.Highlight - }; + private static ChangelogEntry ToEntry(BundledEntry entry) => + new() + { + Prs = entry.Prs, + Issues = entry.Issues, + Type = entry.Type ?? ChangelogEntryType.Invalid, + Subtype = entry.Subtype, + Products = entry.Products, + Areas = entry.Areas, + Title = entry.Title ?? "", + Description = entry.Description, + Impact = entry.Impact, + Action = entry.Action, + FeatureId = entry.FeatureId, + Highlight = entry.Highlight + }; - private static ProductReference ToProductReference(ProductInfoDto dto) => new() - { - ProductId = dto.Product ?? "", - Target = dto.Target, - Lifecycle = ParseLifecycle(dto.Lifecycle) - }; + private static ProductReference ToProductReference(ProductInfoDto dto) => + new() { ProductId = dto.Product ?? "", Target = dto.Target, Lifecycle = ParseLifecycle(dto.Lifecycle) }; - private static Bundle ToBundle(BundleDto dto) => new() - { - Products = dto.Products?.Select(ToBundledProduct).ToList() ?? [], - Description = dto.Description, - ReleaseDate = ParseReleaseDate(dto.ReleaseDate), - GitRef = dto.GitRef, - HideFeatures = dto.HideFeatures ?? [], - Entries = dto.Entries?.Select(ToBundledEntry).ToList() ?? [], - ExcludeEntries = dto.ExcludeEntries?.Select(ToBundledEntry).ToList() ?? [] - }; + private static Bundle ToBundle(BundleDto dto) => + new() + { + Products = dto.Products?.Select(ToBundledProduct).ToList() ?? [], + Description = dto.Description, + ReleaseDate = ParseReleaseDate(dto.ReleaseDate), + GitRef = dto.GitRef, + HideFeatures = dto.HideFeatures ?? [], + Entries = dto.Entries?.Select(ToBundledEntry).ToList() ?? [], + ExcludeEntries = dto.ExcludeEntries?.Select(ToBundledEntry).ToList() ?? [] + }; - private static BundledProduct ToBundledProduct(BundledProductDto dto) => new() - { - ProductId = dto.Product ?? "", - Target = dto.Target, - Lifecycle = ParseLifecycle(dto.Lifecycle), - Repo = dto.Repo, - Owner = dto.Owner - }; + private static BundledProduct ToBundledProduct(BundledProductDto dto) => + new() + { + ProductId = dto.Product ?? "", + Target = dto.Target, + Lifecycle = ParseLifecycle(dto.Lifecycle), + Repo = dto.Repo, + Owner = dto.Owner + }; - private static BundledEntry ToBundledEntry(BundledEntryDto dto) => new() - { - File = dto.File != null ? ToBundledFile(dto.File) : null, - Type = ParseEntryTypeNullable(dto.Type), - Title = dto.Title, - Products = dto.Products?.Select(ToProductReference).ToList(), - Description = dto.Description, - Impact = dto.Impact, - Action = dto.Action, - FeatureId = dto.FeatureId, - Highlight = dto.Highlight, - Subtype = ParseEntrySubtype(dto.Subtype), - Areas = dto.Areas, - Prs = dto.Prs ?? (dto.Pr != null ? [dto.Pr] : null), - Issues = dto.Issues - }; + private static BundledEntry ToBundledEntry(BundledEntryDto dto) => + new() + { + File = dto.File != null ? ToBundledFile(dto.File) : null, + Type = ParseEntryTypeNullable(dto.Type), + Title = dto.Title, + Products = dto.Products?.Select(ToProductReference).ToList(), + Description = dto.Description, + Impact = dto.Impact, + Action = dto.Action, + FeatureId = dto.FeatureId, + Highlight = dto.Highlight, + Subtype = ParseEntrySubtype(dto.Subtype), + Areas = dto.Areas, + Prs = dto.Prs ?? (dto.Pr != null ? [dto.Pr] : null), + Issues = dto.Issues + }; - private static BundledFile ToBundledFile(BundledFileDto dto) => new() - { - Name = dto.Name ?? "", - Checksum = dto.Checksum ?? "" - }; + private static BundledFile ToBundledFile(BundledFileDto dto) => new() { Name = dto.Name ?? "", Checksum = dto.Checksum ?? "" }; private static ChangelogEntryType ParseEntryType(string? value) { @@ -269,97 +264,86 @@ private static ChangelogEntryType ParseEntryType(string? value) if (string.IsNullOrEmpty(value)) return null; - return LifecycleExtensions.TryParse(value, out var result, ignoreCase: true, allowMatchingMetadataAttribute: true) - ? result - : null; + return LifecycleExtensions.TryParse(value, out var result, ignoreCase: true, allowMatchingMetadataAttribute: true) ? result : null; } private static DateOnly? ParseReleaseDate(string? value) => - DateOnly.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) - ? date - : null; + DateOnly.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : null; // Reverse mappings (Domain → DTO) for serialization - private static ChangelogEntryDto ToDto(ChangelogEntry entry) => new() - { - Prs = entry.Prs?.ToList(), - Issues = entry.Issues?.ToList(), - Type = EntryTypeToString(entry.Type), - Subtype = EntrySubtypeToString(entry.Subtype), - Products = entry.Products?.Select(ToDto).ToList(), - Areas = entry.Areas?.ToList(), - Title = entry.Title, - Description = entry.Description, - Impact = entry.Impact, - Action = entry.Action, - FeatureId = entry.FeatureId, - Highlight = entry.Highlight - }; + private static ChangelogEntryDto ToDto(ChangelogEntry entry) => + new() + { + Prs = entry.Prs?.ToList(), + Issues = entry.Issues?.ToList(), + Type = EntryTypeToString(entry.Type), + Subtype = EntrySubtypeToString(entry.Subtype), + Products = entry.Products?.Select(ToDto).ToList(), + Areas = entry.Areas?.ToList(), + Title = entry.Title, + Description = entry.Description, + Impact = entry.Impact, + Action = entry.Action, + FeatureId = entry.FeatureId, + Highlight = entry.Highlight + }; - private static ProductInfoDto ToDto(ProductReference product) => new() - { - Product = product.ProductId, - Target = product.Target, - Lifecycle = LifecycleToString(product.Lifecycle) - }; + private static ProductInfoDto ToDto(ProductReference product) => + new() { Product = product.ProductId, Target = product.Target, Lifecycle = LifecycleToString(product.Lifecycle) }; - private static BundleDto ToDto(Bundle bundle) => new() - { - Products = bundle.Products.Select(ToDto).ToList(), - Description = bundle.Description, - ReleaseDate = bundle.ReleaseDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), - GitRef = bundle.GitRef, - HideFeatures = bundle.HideFeatures.Count > 0 ? bundle.HideFeatures.ToList() : null, - Entries = bundle.Entries.Count > 0 ? bundle.Entries.Select(ToDto).ToList() : null, - ExcludeEntries = bundle.ExcludeEntries.Count > 0 ? bundle.ExcludeEntries.Select(ToDto).ToList() : null - }; + private static BundleDto ToDto(Bundle bundle) => + new() + { + Products = bundle.Products.Select(ToDto).ToList(), + Description = bundle.Description, + ReleaseDate = bundle.ReleaseDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + GitRef = bundle.GitRef, + HideFeatures = bundle.HideFeatures.Count > 0 ? bundle.HideFeatures.ToList() : null, + Entries = bundle.Entries.Count > 0 ? bundle.Entries.Select(ToDto).ToList() : null, + ExcludeEntries = bundle.ExcludeEntries.Count > 0 ? bundle.ExcludeEntries.Select(ToDto).ToList() : null + }; - private static BundledProductDto ToDto(BundledProduct product) => new() - { - Product = product.ProductId, - Target = product.Target, - Lifecycle = LifecycleToString(product.Lifecycle), - Repo = product.Repo, - Owner = product.Owner - }; + private static BundledProductDto ToDto(BundledProduct product) => + new() + { + Product = product.ProductId, + Target = product.Target, + Lifecycle = LifecycleToString(product.Lifecycle), + Repo = product.Repo, + Owner = product.Owner + }; - private static BundledEntryDto ToDto(BundledEntry entry) => new() - { - File = entry.File != null ? ToDto(entry.File) : null, - Type = EntryTypeNullableToString(entry.Type), - Title = entry.Title, - Products = entry.Products?.Select(ToDto).ToList(), - Description = entry.Description, - Impact = entry.Impact, - Action = entry.Action, - FeatureId = entry.FeatureId, - Highlight = entry.Highlight, - Subtype = EntrySubtypeToString(entry.Subtype), - Areas = entry.Areas?.ToList(), - Prs = entry.Prs?.ToList(), - Issues = entry.Issues?.ToList() - }; + private static BundledEntryDto ToDto(BundledEntry entry) => + new() + { + File = entry.File != null ? ToDto(entry.File) : null, + Type = EntryTypeNullableToString(entry.Type), + Title = entry.Title, + Products = entry.Products?.Select(ToDto).ToList(), + Description = entry.Description, + Impact = entry.Impact, + Action = entry.Action, + FeatureId = entry.FeatureId, + Highlight = entry.Highlight, + Subtype = EntrySubtypeToString(entry.Subtype), + Areas = entry.Areas?.ToList(), + Prs = entry.Prs?.ToList(), + Issues = entry.Issues?.ToList() + }; - private static BundledFileDto ToDto(BundledFile file) => new() - { - Name = file.Name, - Checksum = file.Checksum - }; + private static BundledFileDto ToDto(BundledFile file) => new() { Name = file.Name, Checksum = file.Checksum }; // Reverse enum conversion helpers private static string? EntryTypeToString(ChangelogEntryType value) => value != ChangelogEntryType.Invalid ? value.ToStringFast(true) : null; - private static string? EntryTypeNullableToString(ChangelogEntryType? value) => - value?.ToStringFast(true); + private static string? EntryTypeNullableToString(ChangelogEntryType? value) => value?.ToStringFast(true); - private static string? EntrySubtypeToString(ChangelogEntrySubtype? value) => - value?.ToStringFast(true); + private static string? EntrySubtypeToString(ChangelogEntrySubtype? value) => value?.ToStringFast(true); - private static string? LifecycleToString(Lifecycle? value) => - value?.ToStringFast(true); + private static string? LifecycleToString(Lifecycle? value) => value?.ToStringFast(true); #endregion @@ -415,14 +399,13 @@ public static string NormalizeYaml(string yaml) }; } - private static MatchMode? ParseMatchMode(string? value) => - value?.ToLowerInvariant() switch - { - "any" => MatchMode.Any, - "all" => MatchMode.All, - "conjunction" => MatchMode.Conjunction, - _ => null - }; + private static MatchMode? ParseMatchMode(string? value) => value?.ToLowerInvariant() switch + { + "any" => MatchMode.Any, + "all" => MatchMode.All, + "conjunction" => MatchMode.Conjunction, + _ => null + }; } /// diff --git a/src/Elastic.Documentation.Configuration/Search/SearchConfiguration.cs b/src/Elastic.Documentation.Configuration/Search/SearchConfiguration.cs index 55c102af09..499b478c11 100644 --- a/src/Elastic.Documentation.Configuration/Search/SearchConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Search/SearchConfiguration.cs @@ -18,8 +18,8 @@ public required IReadOnlyList Synonyms init { _synonyms = value; - SynonymBiDirectional = value - .SelectMany(a => + SynonymBiDirectional = + value.SelectMany(a => { var targets = new List(); foreach (var s in a) @@ -42,9 +42,9 @@ public required IReadOnlyList Synonyms return targets; }) - .Where(a => a.Length > 1) - .DistinctBy(a => a[0]) - .ToDictionary(a => a[0], a => a.Skip(1).ToArray(), StringComparer.OrdinalIgnoreCase); + .Where(a => a.Length > 1) + .DistinctBy(a => a[0]) + .ToDictionary(a => a[0], a => a.Skip(1).ToArray(), StringComparer.OrdinalIgnoreCase); } } @@ -139,9 +139,7 @@ private static QueryRule ParseRule(QueryRuleDto dto) => new() { RuleId = dto.RuleId, - Type = Enum.TryParse(dto.Type, ignoreCase: true, out var ruleType) - ? ruleType - : QueryRuleType.Pinned, + Type = Enum.TryParse(dto.Type, ignoreCase: true, out var ruleType) ? ruleType : QueryRuleType.Pinned, Criteria = dto.Criteria.Select(ParseCriteria).ToImmutableArray(), Actions = new QueryRuleActions { Ids = dto.Actions.Ids.ToImmutableArray() } }; diff --git a/src/Elastic.Documentation.Configuration/Suggestions/Suggestions.cs b/src/Elastic.Documentation.Configuration/Suggestions/Suggestions.cs index ac194fd314..c5da513c29 100644 --- a/src/Elastic.Documentation.Configuration/Suggestions/Suggestions.cs +++ b/src/Elastic.Documentation.Configuration/Suggestions/Suggestions.cs @@ -7,8 +7,7 @@ namespace Elastic.Documentation.Configuration.Suggestions; public class Suggestion(IReadOnlySet candidates, string input) { private IReadOnlyCollection GetSuggestions() => - candidates - .Select(source => (source, Distance: LevenshteinDistance(input, source))) + candidates.Select(source => (source, Distance: LevenshteinDistance(input, source))) .OrderBy(suggestion => suggestion.Distance) .Where(suggestion => suggestion.Distance <= 2) .Select(suggestion => suggestion.source) @@ -21,7 +20,11 @@ public string GetSuggestionQuestion() if (suggestions.Count == 0) return string.Empty; - return "Did you mean " + string.Join(", ", suggestions.SkipLast(1).Select(s => $"\"{s}\"")) + (suggestions.Count > 1 ? " or " : "") + (suggestions.LastOrDefault() != null ? $"\"{suggestions.LastOrDefault()}\"" : "") + "?"; + return "Did you mean " + + string.Join(", ", suggestions.SkipLast(1).Select(s => $"\"{s}\"")) + + (suggestions.Count > 1 ? " or " : "") + + (suggestions.LastOrDefault() != null ? $"\"{suggestions.LastOrDefault()}\"" : "") + + "?"; } private static int LevenshteinDistance(string source, string target) @@ -52,11 +55,7 @@ private static int LevenshteinDistance(string source, string target) { var cost = (source[i - 1] == target[j - 1]) ? 0 : 1; - distance[i, j] = Math.Min( - Math.Min( - distance[i - 1, j] + 1, - distance[i, j - 1] + 1), - distance[i - 1, j - 1] + cost); + distance[i, j] = Math.Min(Math.Min(distance[i - 1, j] + 1, distance[i, j - 1] + 1), distance[i - 1, j - 1] + cost); } } diff --git a/src/Elastic.Documentation.Configuration/SystemEnvironmentVariables.cs b/src/Elastic.Documentation.Configuration/SystemEnvironmentVariables.cs index c9a7228981..b23bbefffc 100644 --- a/src/Elastic.Documentation.Configuration/SystemEnvironmentVariables.cs +++ b/src/Elastic.Documentation.Configuration/SystemEnvironmentVariables.cs @@ -17,12 +17,10 @@ public class SystemEnvironmentVariables : IEnvironmentVariables public static readonly SystemEnvironmentVariables Instance = new(); /// - public string? GetEnvironmentVariable(string name) => - Environment.GetEnvironmentVariable(name); + public string? GetEnvironmentVariable(string name) => Environment.GetEnvironmentVariable(name); /// - public bool IsRunningOnCI => - !string.IsNullOrEmpty(GetEnvironmentVariable("GITHUB_ACTIONS")); + public bool IsRunningOnCI => !string.IsNullOrEmpty(GetEnvironmentVariable("GITHUB_ACTIONS")); /// public string ApiPrefix => GetEnvironmentVariable("DOCS_API_PREFIX") ?? "/docs/_api"; @@ -33,7 +31,7 @@ public class SystemEnvironmentVariables : IEnvironmentVariables /// public bool McpAuthEnabled => string.Equals(GetEnvironmentVariable("MCP_AUTH_ENABLED"), "true", StringComparison.OrdinalIgnoreCase) || - string.Equals(GetEnvironmentVariable("MCP_AUTH_ENABLED"), "1", StringComparison.OrdinalIgnoreCase); + string.Equals(GetEnvironmentVariable("MCP_AUTH_ENABLED"), "1", StringComparison.OrdinalIgnoreCase); /// public string? McpJwtPublicKey => GetEnvironmentVariable("MCP_JWT_PUBLIC_KEY"); @@ -49,5 +47,4 @@ public class SystemEnvironmentVariables : IEnvironmentVariables /// public string McpServerProfile => GetEnvironmentVariable("MCP_SERVER_PROFILE") ?? "public"; - } diff --git a/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs b/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs index 43158b5d47..6cffd82aa6 100644 --- a/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs @@ -185,6 +185,9 @@ public class ResolvedApiConfiguration public IEnumerable GetMarkdownPathsToExclude(string documentationSourceDirectoryFullName) { foreach (var file in Children) - yield return Path.GetRelativePath(documentationSourceDirectoryFullName, file.FullName).Replace(Path.DirectorySeparatorChar, '/'); + yield return Path.GetRelativePath(documentationSourceDirectoryFullName, file.FullName).Replace( + Path.DirectorySeparatorChar, + '/' + ); } } diff --git a/src/Elastic.Documentation.Configuration/Toc/ApiConfigurationConverter.cs b/src/Elastic.Documentation.Configuration/Toc/ApiConfigurationConverter.cs index c40989e3b1..973a3c816b 100644 --- a/src/Elastic.Documentation.Configuration/Toc/ApiConfigurationConverter.cs +++ b/src/Elastic.Documentation.Configuration/Toc/ApiConfigurationConverter.cs @@ -25,15 +25,14 @@ namespace Elastic.Documentation.Configuration.Toc; /// public class ApiConfigurationConverter : IYamlTypeConverter { - private const string ShapeGuidance = - "Use the single-entry sequence form instead:\n" + - " :\n" + - " - spec: # required; its basename resolves the remote version index\n" + - " product: # required, must match a products.yml entry\n" + - " repository: # optional; only needed if the spec is published from a\n" + - " # different repo than the current checkout\n" + - " children: # optional\n" + - " - file: getting-started.md"; + private const string ShapeGuidance = "Use the single-entry sequence form instead:\n" + + " :\n" + + " - spec: # required; its basename resolves the remote version index\n" + + " product: # required, must match a products.yml entry\n" + + " repository: # optional; only needed if the spec is published from a\n" + + " # different repo than the current checkout\n" + + " children: # optional\n" + + " - file: getting-started.md"; public bool Accepts(Type type) => type == typeof(ApiProductSequence) || type == typeof(ApiProductEntry); @@ -44,8 +43,11 @@ private ApiProductSequence ReadSequence(IParser parser) { if (parser.Current is not SequenceStart) { - throw new YamlException(parser.Current?.Start ?? Mark.Empty, parser.Current?.End ?? Mark.Empty, - $"API configuration for this key must be a sequence with exactly one entry. {ShapeGuidance}"); + throw new YamlException( + parser.Current?.Start ?? Mark.Empty, + parser.Current?.End ?? Mark.Empty, + $"API configuration for this key must be a sequence with exactly one entry. {ShapeGuidance}" + ); } _ = parser.MoveNext(); // consume SequenceStart @@ -61,18 +63,17 @@ private ApiProductEntry ReadEntry(IParser parser) { if (parser.Current is not MappingStart) { - throw new YamlException(parser.Current?.Start ?? Mark.Empty, parser.Current?.End ?? Mark.Empty, - $"Each API entry must be a mapping with 'spec', 'product', and optional 'children' keys. {ShapeGuidance}"); + throw new YamlException( + parser.Current?.Start ?? Mark.Empty, + parser.Current?.End ?? Mark.Empty, + $"Each API entry must be a mapping with 'spec', 'product', and optional 'children' keys. {ShapeGuidance}" + ); } var entryStart = parser.Current.Start; _ = parser.MoveNext(); // consume MappingStart - var entry = new ApiProductEntry - { - Line = (int)entryStart.Line, - Column = (int)entryStart.Column - }; + var entry = new ApiProductEntry { Line = (int)entryStart.Line, Column = (int)entryStart.Column }; while (parser.Current is not MappingEnd) { @@ -128,8 +129,11 @@ private ApiProductEntry ReadEntry(IParser parser) entry.Children = ReadChildren(parser); break; case "file": - throw new YamlException(key.Start, key.End, - $"'file:' entries directly in the api sequence (legacy intro/outro shape) are no longer supported. {ShapeGuidance}"); + throw new YamlException( + key.Start, + key.End, + $"'file:' entries directly in the api sequence (legacy intro/outro shape) are no longer supported. {ShapeGuidance}" + ); default: // Forward-compatible: ignore unrecognized keys rather than failing the whole build. parser.SkipThisAndNestedEvents(); diff --git a/src/Elastic.Documentation.Configuration/Toc/CliReference/CliSchema.cs b/src/Elastic.Documentation.Configuration/Toc/CliReference/CliSchema.cs index 8976a7de41..f5f9d2c054 100644 --- a/src/Elastic.Documentation.Configuration/Toc/CliReference/CliSchema.cs +++ b/src/Elastic.Documentation.Configuration/Toc/CliReference/CliSchema.cs @@ -29,8 +29,8 @@ public record CliSchema( public static CliSchema Load(IFileInfo schemaFile) { var json = schemaFile.FileSystem.File.ReadAllText(schemaFile.FullName); - return JsonSerializer.Deserialize(json, CliSchemaJsonContext.Default.CliSchema) - ?? throw new InvalidOperationException($"Failed to deserialize CLI schema from {schemaFile.FullName}"); + return JsonSerializer.Deserialize(json, CliSchemaJsonContext.Default.CliSchema) ?? + throw new InvalidOperationException($"Failed to deserialize CLI schema from {schemaFile.FullName}"); } } @@ -95,19 +95,9 @@ public record CliDefaultSchema( bool Hidden = false ); -public record CliValidationSchema( - string Kind, - string[]? Values = null, - string? Min = null, - string? Max = null, - string? Pattern = null -); +public record CliValidationSchema(string Kind, string[]? Values = null, string? Min = null, string? Max = null, string? Pattern = null); -public record CliDeprecatedSchema( - string? Message = null, - string? Since = null, - string? RemovedIn = null -); +public record CliDeprecatedSchema(string? Message = null, string? Since = null, string? RemovedIn = null); public record CliIntentSchema( bool? Destructive = null, @@ -117,25 +107,10 @@ public record CliIntentSchema( bool? RequiresAuth = null ); -public record CliOutputSchema( - string[]? Formats = null, - string? FormatFlag = null -); +public record CliOutputSchema(string[]? Formats = null, string? FormatFlag = null); -public record CliEnvironmentSchema( - List? Variables = null, - List? ConfigFiles = null -); +public record CliEnvironmentSchema(List? Variables = null, List? ConfigFiles = null); -public record CliEnvVarSchema( - string Name, - string? Description = null, - bool Required = false, - string? DefaultValue = null -); +public record CliEnvVarSchema(string Name, string? Description = null, bool Required = false, string? DefaultValue = null); -public record CliConfigFileSchema( - string Path, - string? Description = null, - bool Required = false -); +public record CliConfigFileSchema(string Path, string? Description = null, bool Required = false); diff --git a/src/Elastic.Documentation.Configuration/Toc/DetectionRules/DetectionRuleOverviewRef.cs b/src/Elastic.Documentation.Configuration/Toc/DetectionRules/DetectionRuleOverviewRef.cs index fd92a37e83..71a4a7f996 100644 --- a/src/Elastic.Documentation.Configuration/Toc/DetectionRules/DetectionRuleOverviewRef.cs +++ b/src/Elastic.Documentation.Configuration/Toc/DetectionRules/DetectionRuleOverviewRef.cs @@ -36,7 +36,11 @@ public DetectionRuleOverviewRef( DeprecatedFile = deprecatedFile; } - public static IReadOnlyCollection CreateTableOfContentItems(IReadOnlyCollection sourceFolders, string context, IDirectoryInfo baseDirectory) + public static IReadOnlyCollection CreateTableOfContentItems( + IReadOnlyCollection sourceFolders, + string context, + IDirectoryInfo baseDirectory + ) { var tocItems = new List(); foreach (var detectionRuleFolder in sourceFolders) @@ -45,11 +49,14 @@ public static IReadOnlyCollection CreateTableOfContentItem tocItems.AddRange(children); } - return tocItems - .ToArray(); + return tocItems.ToArray(); } - public static IReadOnlyCollection CreateDeprecatedTableOfContentItems(IReadOnlyCollection sourceFolders, string context, IDirectoryInfo baseDirectory) + public static IReadOnlyCollection CreateDeprecatedTableOfContentItems( + IReadOnlyCollection sourceFolders, + string context, + IDirectoryInfo baseDirectory + ) { var tocItems = new List(); foreach (var detectionRuleFolder in sourceFolders) @@ -61,10 +68,13 @@ public static IReadOnlyCollection CreateDeprecatedTableOfC return tocItems.ToArray(); } - private static IReadOnlyCollection ReadDetectionRuleFolder(IDirectoryInfo directory, string context, IDirectoryInfo baseDirectory) + private static IReadOnlyCollection ReadDetectionRuleFolder( + IDirectoryInfo directory, + string context, + IDirectoryInfo baseDirectory + ) { - IReadOnlyCollection children = directory - .EnumerateFiles("*.*", SearchOption.AllDirectories) + IReadOnlyCollection children = directory.EnumerateFiles("*.*", SearchOption.AllDirectories) .Where(f => !f.Attributes.HasFlag(FileAttributes.Hidden) && !f.Attributes.HasFlag(FileAttributes.System)) .Where(f => !f.Directory!.Attributes.HasFlag(FileAttributes.Hidden) && !f.Directory!.Attributes.HasFlag(FileAttributes.System)) // skip symlinks @@ -86,10 +96,13 @@ private static IReadOnlyCollection ReadDetectionRuleFolder return children; } - private static IReadOnlyCollection ReadDeprecatedDetectionRuleFolder(IDirectoryInfo directory, string context, IDirectoryInfo baseDirectory) + private static IReadOnlyCollection ReadDeprecatedDetectionRuleFolder( + IDirectoryInfo directory, + string context, + IDirectoryInfo baseDirectory + ) { - IReadOnlyCollection children = directory - .EnumerateFiles("*.*", SearchOption.AllDirectories) + IReadOnlyCollection children = directory.EnumerateFiles("*.*", SearchOption.AllDirectories) .Where(f => !f.Attributes.HasFlag(FileAttributes.Hidden) && !f.Attributes.HasFlag(FileAttributes.System)) .Where(f => !f.Directory!.Attributes.HasFlag(FileAttributes.Hidden) && !f.Directory!.Attributes.HasFlag(FileAttributes.System)) // skip symlinks diff --git a/src/Elastic.Documentation.Configuration/Toc/DetectionRules/DetectionRuleRef.cs b/src/Elastic.Documentation.Configuration/Toc/DetectionRules/DetectionRuleRef.cs index aef2c4a8e7..2ce388fc2c 100644 --- a/src/Elastic.Documentation.Configuration/Toc/DetectionRules/DetectionRuleRef.cs +++ b/src/Elastic.Documentation.Configuration/Toc/DetectionRules/DetectionRuleRef.cs @@ -6,5 +6,10 @@ namespace Elastic.Documentation.Configuration.Toc.DetectionRules; -public record DetectionRuleRef(IFileInfo FileInfo, string PathRelativeToDocumentationSet, string Context) - : FileRef(PathRelativeToDocumentationSet, PathRelativeToDocumentationSet, true, [], Context); +public record DetectionRuleRef(IFileInfo FileInfo, string PathRelativeToDocumentationSet, string Context) : FileRef( + PathRelativeToDocumentationSet, + PathRelativeToDocumentationSet, + true, + [], + Context +); diff --git a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs index f29db26c9f..47ee38da9b 100644 --- a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs +++ b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs @@ -127,7 +127,12 @@ public static DocumentationSetFile LoadMetadata(IFileInfo file) /// replacing them with their resolved children and ensuring file paths carry over parent paths. /// Validates the table of contents structure and emits diagnostics for issues. /// - public static DocumentationSetFile LoadAndResolve(IDiagnosticsCollector collector, IFileInfo docsetPath, ScopedFileSystem? fileSystem = null, HashSet? noSuppress = null) + public static DocumentationSetFile LoadAndResolve( + IDiagnosticsCollector collector, + IFileInfo docsetPath, + ScopedFileSystem? fileSystem = null, + HashSet? noSuppress = null + ) { fileSystem ??= new CheckoutsFileSystem(docsetPath.Directory!, inner: docsetPath.FileSystem); // Validate that the docset.yml is not a symlink (security: prevents path traversal attacks) @@ -149,19 +154,34 @@ public static DocumentationSetFile LoadAndResolve(IDiagnosticsCollector collecto /// replacing them with their resolved children and ensuring file paths carry over parent paths. /// Validates the table of contents structure and emits diagnostics for issues. /// - public static DocumentationSetFile LoadAndResolve(IDiagnosticsCollector collector, string yaml, IDirectoryInfo sourceDirectory, ScopedFileSystem? fileSystem = null, HashSet? noSuppress = null) + public static DocumentationSetFile LoadAndResolve( + IDiagnosticsCollector collector, + string yaml, + IDirectoryInfo sourceDirectory, + ScopedFileSystem? fileSystem = null, + HashSet? noSuppress = null + ) { fileSystem ??= new CheckoutsFileSystem(sourceDirectory, inner: sourceDirectory.FileSystem); var docSet = Deserialize(yaml); var docsetPath = fileSystem.Path.Join(sourceDirectory.FullName, "docset.yml").OptionalWindowsReplace(); docSet.SuppressDiagnostics.ExceptWith(noSuppress ?? []); - docSet.TableOfContents = ResolveTableOfContents(collector, docSet.TableOfContents, sourceDirectory, fileSystem, parentPath: "", containerPath: "", context: docsetPath, docSet.SuppressDiagnostics); + docSet.TableOfContents = + ResolveTableOfContents( + collector, + docSet.TableOfContents, + sourceDirectory, + fileSystem, + parentPath: "", + containerPath: "", + context: docsetPath, + docSet.SuppressDiagnostics + ); // Collect excluded paths so they can be skipped during file processing (not just navigation) docSet.FolderExcludedFiles = CollectFolderExcludedFiles(docSet.TableOfContents); return docSet; } - /// /// Recursively resolves all IsolatedTableOfContentsRef items in a table of contents, /// loading nested TOC files and prepending parent paths to all file references. @@ -185,13 +205,47 @@ private static TableOfContents ResolveTableOfContents( { var resolvedItem = item switch { - IsolatedTableOfContentsRef tocRef => ResolveIsolatedToc(collector, tocRef, baseDirectory, fileSystem, parentPath, containerPath, context, suppressDiagnostics), - DetectionRuleOverviewRef ruleOverviewReference => ResolveRuleOverviewReference(collector, ruleOverviewReference, baseDirectory, fileSystem, parentPath, containerPath, context, suppressDiagnostics), - CliReferenceRef cliRef => ResolveCliReference(collector, cliRef, baseDirectory, fileSystem, parentPath, containerPath, context), - ListingRef listingRef => ResolveListingRef(collector, listingRef, baseDirectory, fileSystem, parentPath, containerPath, context), - FileRef fileRef => ResolveFileRef(collector, fileRef, baseDirectory, fileSystem, parentPath, containerPath, context, suppressDiagnostics), - FolderRef folderRef => ResolveFolderRef(collector, folderRef, baseDirectory, fileSystem, parentPath, containerPath, context, suppressDiagnostics), - CrossLinkRef crossLink => ResolveCrossLinkRef(collector, crossLink, baseDirectory, fileSystem, parentPath, containerPath, context), + IsolatedTableOfContentsRef tocRef => + ResolveIsolatedToc( + collector, + tocRef, + baseDirectory, + fileSystem, + parentPath, + containerPath, + context, + suppressDiagnostics + ), + DetectionRuleOverviewRef ruleOverviewReference => + ResolveRuleOverviewReference( + collector, + ruleOverviewReference, + baseDirectory, + fileSystem, + parentPath, + containerPath, + context, + suppressDiagnostics + ), + CliReferenceRef cliRef => + ResolveCliReference(collector, cliRef, baseDirectory, fileSystem, parentPath, containerPath, context), + ListingRef listingRef => + ResolveListingRef(collector, listingRef, baseDirectory, fileSystem, parentPath, containerPath, context), + FileRef fileRef => + ResolveFileRef(collector, fileRef, baseDirectory, fileSystem, parentPath, containerPath, context, suppressDiagnostics), + FolderRef folderRef => + ResolveFolderRef( + collector, + folderRef, + baseDirectory, + fileSystem, + parentPath, + containerPath, + context, + suppressDiagnostics + ), + CrossLinkRef crossLink => + ResolveCrossLinkRef(collector, crossLink, baseDirectory, fileSystem, parentPath, containerPath, context), _ => null }; @@ -213,7 +267,8 @@ private static TableOfContents ResolveTableOfContents( /// The TOC's path is set to the full path (including parent path) for consistency with files and folders. /// #pragma warning disable IDE0060 // Remove unused parameter - suppressDiagnostics is for consistency, nested TOCs use their own suppression config - private static ITableOfContentsItem? ResolveIsolatedToc(IDiagnosticsCollector collector, + private static ITableOfContentsItem? ResolveIsolatedToc( + IDiagnosticsCollector collector, IsolatedTableOfContentsRef tocRef, IDirectoryInfo baseDirectory, IFileSystem fileSystem, @@ -242,7 +297,9 @@ private static TableOfContents ResolveTableOfContents( else { // Simple name, resolve relative to parent path - fullTocPath = string.IsNullOrEmpty(parentPath) ? tocRef.PathRelativeToDocumentationSet : $"{parentPath}/{tocRef.PathRelativeToDocumentationSet}"; + fullTocPath = string.IsNullOrEmpty(parentPath) + ? tocRef.PathRelativeToDocumentationSet + : $"{parentPath}/{tocRef.PathRelativeToDocumentationSet}"; } var tocDirectory = fileSystem.DirectoryInfo.New(fileSystem.Path.Join(baseDirectory.FullName, fullTocPath)); @@ -252,8 +309,10 @@ private static TableOfContents ResolveTableOfContents( // Validate: TOC should not have children defined in parent YAML if (tocRef.Children.Count > 0) { - collector.EmitError(parentContext, - $"TableOfContents '{fullTocPath}' may not contain children, define children in '{fullTocPath}/toc.yml' instead."); + collector.EmitError( + parentContext, + $"TableOfContents '{fullTocPath}' may not contain children, define children in '{fullTocPath}/toc.yml' instead." + ); return null; } @@ -279,7 +338,8 @@ private static TableOfContents ResolveTableOfContents( // this is temporary after this lands in main we can update these files to include // suppress: // - DeepLinkingVirtualFile - string[] skip = [ + string[] skip = + [ "docs-content/solutions/toc.yml", "docs-content/manage-data/toc.yml", "docs-content/explore-analyze/toc.yml", @@ -294,12 +354,20 @@ private static TableOfContents ResolveTableOfContents( if (skip.Any(f => path.Contains(f, StringComparison.OrdinalIgnoreCase))) _ = nestedTocFile.SuppressDiagnostics.Add(HintType.DeepLinkingVirtualFile); - // Recursively resolve children with the FULL TOC path as the parent path // This ensures all file paths within the TOC include the TOC directory path // The context for children is the toc.yml file that defines them // For children of this TOC, the container path is fullTocPath (they're defined in toc.yml at that location) - var resolvedChildren = ResolveTableOfContents(collector, nestedTocFile.TableOfContents, baseDirectory, fileSystem, fullTocPath, fullTocPath, tocFilePath, nestedTocFile.SuppressDiagnostics); + var resolvedChildren = ResolveTableOfContents( + collector, + nestedTocFile.TableOfContents, + baseDirectory, + fileSystem, + fullTocPath, + fullTocPath, + tocFilePath, + nestedTocFile.SuppressDiagnostics + ); // Validate: TOC must have at least one child if (resolvedChildren.Count == 0) @@ -307,23 +375,33 @@ private static TableOfContents ResolveTableOfContents( // Return TOC ref with FULL path and resolved children. // Island flag is OR-ed: either the inline `- toc:` entry or the child toc.yml root can opt in. - return new IsolatedTableOfContentsRef(fullTocPath, tocPathRelativeToContainer, resolvedChildren, parentContext, tocRef.Island || nestedTocFile.Island); + return new IsolatedTableOfContentsRef( + fullTocPath, + tocPathRelativeToContainer, + resolvedChildren, + parentContext, + tocRef.Island || nestedTocFile.Island + ); } /// /// Resolves a FileRef by prepending the parent path to the file path and recursively resolving children. /// The parent path provides the correct context for child resolution. /// - private static ITableOfContentsItem ResolveFileRef(IDiagnosticsCollector collector, + private static ITableOfContentsItem ResolveFileRef( + IDiagnosticsCollector collector, FileRef fileRef, IDirectoryInfo baseDirectory, IFileSystem fileSystem, string parentPath, string containerPath, string context, - HashSet? suppressDiagnostics = null) + HashSet? suppressDiagnostics = null + ) { - var fullPath = string.IsNullOrEmpty(parentPath) ? fileRef.PathRelativeToDocumentationSet : $"{parentPath}/{fileRef.PathRelativeToDocumentationSet}"; + var fullPath = string.IsNullOrEmpty(parentPath) + ? fileRef.PathRelativeToDocumentationSet + : $"{parentPath}/{fileRef.PathRelativeToDocumentationSet}"; // Special validation for FolderIndexFileRef (folder+file combination) // Validate BEFORE early return so we catch cases with no children @@ -336,8 +414,10 @@ private static ITableOfContentsItem ResolveFileRef(IDiagnosticsCollector collect // The file path should be simple (no '/'), or at most folder/file.md after prepending if (fileName.Contains('/')) { - collector.EmitError(context, - $"Deep linking on folder 'file' is not supported. Found file path '{fileName}' with '/'. Use simple file name only."); + collector.EmitError( + context, + $"Deep linking on folder 'file' is not supported. Found file path '{fileName}' with '/'. Use simple file name only." + ); } // Best practice: file name should match folder name (from parentPath) @@ -352,22 +432,26 @@ private static ITableOfContentsItem ResolveFileRef(IDiagnosticsCollector collect // Normalize for comparison: remove hyphens, underscores, and lowercase // This allows "getting-started" to match "GettingStarted" or "getting_started" - var normalizedFile = fileWithoutExtension.Replace("-", "", StringComparison.Ordinal).Replace("_", "", StringComparison.Ordinal).ToLowerInvariant(); - var normalizedFolder = folderName.Replace("-", "", StringComparison.Ordinal).Replace("_", "", StringComparison.Ordinal).ToLowerInvariant(); + var normalizedFile = fileWithoutExtension.Replace("-", "", StringComparison.Ordinal) + .Replace("_", "", StringComparison.Ordinal) + .ToLowerInvariant(); + var normalizedFolder = folderName.Replace("-", "", StringComparison.Ordinal) + .Replace("_", "", StringComparison.Ordinal) + .ToLowerInvariant(); if (!normalizedFile.Equals(normalizedFolder, StringComparison.Ordinal)) { - collector.EmitHint(context, - $"File name '{fileName}' does not match folder name '{folderName}'. Best practice is to name the file the same as the folder (e.g., 'folder: {folderName}, file: {folderName}.md')."); + collector.EmitHint( + context, + $"File name '{fileName}' does not match folder name '{folderName}'. Best practice is to name the file the same as the folder (e.g., 'folder: {folderName}, file: {folderName}.md')." + ); } } } } // Calculate PathRelativeToContainer: the file path relative to its container - var pathRelativeToContainer = string.IsNullOrEmpty(containerPath) - ? fullPath - : fullPath.Substring(containerPath.Length + 1); + var pathRelativeToContainer = string.IsNullOrEmpty(containerPath) ? fullPath : fullPath.Substring(containerPath.Length + 1); if (fileRef.Children.Count == 0) { @@ -387,8 +471,10 @@ private static ITableOfContentsItem ResolveFileRef(IDiagnosticsCollector collect // Check if this hint type should be suppressed if (!suppressDiagnostics.ShouldSuppress(HintType.DeepLinkingVirtualFile)) { - collector.EmitHint(context, - $"File '{fileRef.PathRelativeToDocumentationSet}' uses deep-linking with children. Consider using 'folder' instead of 'file' for better navigation structure. Virtual files are primarily intended to group sibling files together."); + collector.EmitHint( + context, + $"File '{fileRef.PathRelativeToDocumentationSet}' uses deep-linking with children. Consider using 'folder' instead of 'file' for better navigation structure. Virtual files are primarily intended to group sibling files together." + ); } } @@ -421,7 +507,16 @@ private static ITableOfContentsItem ResolveFileRef(IDiagnosticsCollector collect } // For children of files, the container is still the current context (same container as the file itself) - var resolvedChildren = ResolveTableOfContents(collector, fileRef.Children, baseDirectory, fileSystem, parentPathForChildren, containerPath, context, suppressDiagnostics); + var resolvedChildren = ResolveTableOfContents( + collector, + fileRef.Children, + baseDirectory, + fileSystem, + parentPathForChildren, + containerPath, + context, + suppressDiagnostics + ); // Preserve the specific type when creating the resolved reference return fileRef switch @@ -436,14 +531,16 @@ private static ITableOfContentsItem ResolveFileRef(IDiagnosticsCollector collect /// Resolves a FolderRef by prepending the parent path to the folder path and recursively resolving children. /// If no children are defined, auto-discovers .md files in the folder directory. /// - private static ITableOfContentsItem ResolveRuleOverviewReference(IDiagnosticsCollector collector, + private static ITableOfContentsItem ResolveRuleOverviewReference( + IDiagnosticsCollector collector, DetectionRuleOverviewRef detectionRuleRef, IDirectoryInfo baseDirectory, IFileSystem fileSystem, string parentPath, string containerPath, string context, - HashSet? suppressDiagnostics = null) + HashSet? suppressDiagnostics = null + ) { // Folder paths containing '/' are treated as relative to the context file's directory (full paths). // Simple folder names (no '/') are resolved relative to the parent path in the navigation hierarchy. @@ -463,16 +560,25 @@ private static ITableOfContentsItem ResolveRuleOverviewReference(IDiagnosticsCol else { // Simple name, resolve relative to parent path - fullPath = string.IsNullOrEmpty(parentPath) ? detectionRuleRef.PathRelativeToDocumentationSet : $"{parentPath}/{detectionRuleRef.PathRelativeToDocumentationSet}"; + fullPath = string.IsNullOrEmpty(parentPath) + ? detectionRuleRef.PathRelativeToDocumentationSet + : $"{parentPath}/{detectionRuleRef.PathRelativeToDocumentationSet}"; } // Calculate PathRelativeToContainer: the folder path relative to its container - var pathRelativeToContainer = string.IsNullOrEmpty(containerPath) - ? fullPath - : fullPath.Substring(containerPath.Length + 1); + var pathRelativeToContainer = string.IsNullOrEmpty(containerPath) ? fullPath : fullPath.Substring(containerPath.Length + 1); // For children of folders, the container remains the same as the folder's container - var resolvedChildren = ResolveTableOfContents(collector, detectionRuleRef.Children, baseDirectory, fileSystem, fullPath, containerPath, context, suppressDiagnostics); + var resolvedChildren = ResolveTableOfContents( + collector, + detectionRuleRef.Children, + baseDirectory, + fileSystem, + fullPath, + containerPath, + context, + suppressDiagnostics + ); var fileInfo = fileSystem.NewFileInfo(baseDirectory.FullName, fullPath); var tocSourceFolders = detectionRuleRef.DetectionRuleFolders @@ -487,29 +593,37 @@ private static ITableOfContentsItem ResolveRuleOverviewReference(IDiagnosticsCol // and attach it as DeprecatedSiblingRef so ResolveTableOfContents can emit it as a sibling, // not as a child nested under the active rules. FileRef? deprecatedSiblingRef = null; - var hasDeprecatedRules = tocSourceFolders.Any(d => - d.Exists && d.EnumerateDirectories("_deprecated", SearchOption.TopDirectoryOnly).Any()); + var hasDeprecatedRules = tocSourceFolders.Any( + d => d.Exists && d.EnumerateDirectories("_deprecated", SearchOption.TopDirectoryOnly).Any() + ); if (hasDeprecatedRules) { var deprecatedFileName = detectionRuleRef.DeprecatedFile ?? "deprecated-detection-rules.md"; var overviewDir = fileSystem.Path.GetDirectoryName(fullPath); - var deprecatedFullPath = string.IsNullOrEmpty(overviewDir) - ? deprecatedFileName - : $"{overviewDir}/{deprecatedFileName}"; + var deprecatedFullPath = string.IsNullOrEmpty(overviewDir) ? deprecatedFileName : $"{overviewDir}/{deprecatedFileName}"; var deprecatedPathRelativeToContainer = string.IsNullOrEmpty(containerPath) ? deprecatedFullPath : deprecatedFullPath.Substring(containerPath.Length + 1); - var deprecatedTomlChildren = DetectionRuleOverviewRef.CreateDeprecatedTableOfContentItems(tocSourceFolders, context, baseDirectory); - deprecatedSiblingRef = new FileRef(deprecatedFullPath, deprecatedPathRelativeToContainer, false, deprecatedTomlChildren, context); + var deprecatedTomlChildren = DetectionRuleOverviewRef.CreateDeprecatedTableOfContentItems( + tocSourceFolders, + context, + baseDirectory + ); + deprecatedSiblingRef = + new FileRef(deprecatedFullPath, deprecatedPathRelativeToContainer, false, deprecatedTomlChildren, context); } - return new DetectionRuleOverviewRef(fullPath, pathRelativeToContainer, detectionRuleRef.DetectionRuleFolders, children, context, detectionRuleRef.DeprecatedFile) - { - DeprecatedSiblingRef = deprecatedSiblingRef - }; + return new DetectionRuleOverviewRef( + fullPath, + pathRelativeToContainer, + detectionRuleRef.DetectionRuleFolders, + children, + context, + detectionRuleRef.DeprecatedFile + ) + { DeprecatedSiblingRef = deprecatedSiblingRef }; } - private static ITableOfContentsItem? ResolveCliReference( IDiagnosticsCollector collector, CliReferenceRef cliRef, @@ -517,7 +631,8 @@ private static ITableOfContentsItem ResolveRuleOverviewReference(IDiagnosticsCol IFileSystem fileSystem, string parentPath, string containerPath, - string context) + string context + ) { // Resolve schema path relative to docset root (context-relative for paths with '/') string schemaFullPath; @@ -527,15 +642,11 @@ private static ITableOfContentsItem ResolveRuleOverviewReference(IDiagnosticsCol var contextRelativePath = fileSystem.Path.GetRelativePath(baseDirectory.FullName, contextDir); if (contextRelativePath == ".") contextRelativePath = ""; - schemaFullPath = string.IsNullOrEmpty(contextRelativePath) - ? cliRef.SchemaPath - : $"{contextRelativePath}/{cliRef.SchemaPath}"; + schemaFullPath = string.IsNullOrEmpty(contextRelativePath) ? cliRef.SchemaPath : $"{contextRelativePath}/{cliRef.SchemaPath}"; } else { - schemaFullPath = string.IsNullOrEmpty(parentPath) - ? cliRef.SchemaPath - : $"{parentPath}/{cliRef.SchemaPath}"; + schemaFullPath = string.IsNullOrEmpty(parentPath) ? cliRef.SchemaPath : $"{parentPath}/{cliRef.SchemaPath}"; } var schemaFileInfo = fileSystem.FileInfo.New(fileSystem.Path.Join(baseDirectory.FullName, schemaFullPath)); @@ -551,9 +662,7 @@ private static ITableOfContentsItem ResolveRuleOverviewReference(IDiagnosticsCol : Path.ChangeExtension(schemaFullPath, null); var fullVirtualRoot = string.IsNullOrEmpty(parentPath) ? virtualRoot : $"{parentPath}/{virtualRoot}"; - var pathRelativeToContainer = string.IsNullOrEmpty(containerPath) - ? fullVirtualRoot - : fullVirtualRoot[(containerPath.Length + 1)..]; + var pathRelativeToContainer = string.IsNullOrEmpty(containerPath) ? fullVirtualRoot : fullVirtualRoot[(containerPath.Length + 1)..]; if (cliRef.SupplementalFolder is not null) { @@ -567,21 +676,32 @@ private static ITableOfContentsItem ResolveRuleOverviewReference(IDiagnosticsCol ? ResolveTableOfContents(collector, cliRef.Children, baseDirectory, fileSystem, fullVirtualRoot, containerPath, context) : []; - return new CliReferenceRef(schemaFullPath, cliRef.SupplementalFolder, cliRef.Title, cliRef.NavigationTitle, fullVirtualRoot, pathRelativeToContainer, context, resolvedChildren); + return new CliReferenceRef( + schemaFullPath, + cliRef.SupplementalFolder, + cliRef.Title, + cliRef.NavigationTitle, + fullVirtualRoot, + pathRelativeToContainer, + context, + resolvedChildren + ); } /// /// Resolves a FolderRef by prepending the parent path to the folder path and recursively resolving children. /// If no children are defined, auto-discovers .md files in the folder directory. /// - private static ITableOfContentsItem ResolveFolderRef(IDiagnosticsCollector collector, + private static ITableOfContentsItem ResolveFolderRef( + IDiagnosticsCollector collector, FolderRef folderRef, IDirectoryInfo baseDirectory, IFileSystem fileSystem, string parentPath, string containerPath, string context, - HashSet? suppressDiagnostics = null) + HashSet? suppressDiagnostics = null + ) { // Folder paths containing '/' are treated as relative to the context file's directory (full paths). // Simple folder names (no '/') are resolved relative to the parent path in the navigation hierarchy. @@ -601,27 +721,36 @@ private static ITableOfContentsItem ResolveFolderRef(IDiagnosticsCollector colle else { // Simple name, resolve relative to parent path - fullPath = string.IsNullOrEmpty(parentPath) ? folderRef.PathRelativeToDocumentationSet : $"{parentPath}/{folderRef.PathRelativeToDocumentationSet}"; + fullPath = string.IsNullOrEmpty(parentPath) + ? folderRef.PathRelativeToDocumentationSet + : $"{parentPath}/{folderRef.PathRelativeToDocumentationSet}"; } // Calculate PathRelativeToContainer: the folder path relative to its container - var pathRelativeToContainer = string.IsNullOrEmpty(containerPath) - ? fullPath - : fullPath.Substring(containerPath.Length + 1); + var pathRelativeToContainer = string.IsNullOrEmpty(containerPath) ? fullPath : fullPath.Substring(containerPath.Length + 1); // Parse and validate sort order if (!SortOrderExtensions.TryParse(folderRef.Sort, out var sortOrder) && folderRef.Sort is not null) collector.EmitError( context, - $"Unknown sort order '{folderRef.Sort}' for folder '{folderRef.PathRelativeToDocumentationSet}'." - + " Valid values are: asc, ascending, desc, descending." + $"Unknown sort order '{folderRef.Sort}' for folder '{folderRef.PathRelativeToDocumentationSet}'." + + " Valid values are: asc, ascending, desc, descending." ); // If children are explicitly defined, resolve them if (folderRef.Children.Count > 0) { // For children of folders, the container remains the same as the folder's container - var resolvedChildren = ResolveTableOfContents(collector, folderRef.Children, baseDirectory, fileSystem, fullPath, containerPath, context, suppressDiagnostics); + var resolvedChildren = ResolveTableOfContents( + collector, + folderRef.Children, + baseDirectory, + fileSystem, + fullPath, + containerPath, + context, + suppressDiagnostics + ); // Exclude is intentionally not passed through — it only applies to auto-discovery return new FolderRef(fullPath, pathRelativeToContainer, resolvedChildren, context, folderRef.Sort); } @@ -629,7 +758,16 @@ private static ITableOfContentsItem ResolveFolderRef(IDiagnosticsCollector colle // No children defined - auto-discover .md files in the folder // null preserves the default alphabetical sorting; non-null enables natural sort for version numbers var explicitSortOrder = folderRef.Sort is not null ? sortOrder : (SortOrder?)null; - var autoDiscoveredChildren = AutoDiscoverFolderFiles(collector, fullPath, containerPath, baseDirectory, fileSystem, context, explicitSortOrder, folderRef.Exclude); + var autoDiscoveredChildren = AutoDiscoverFolderFiles( + collector, + fullPath, + containerPath, + baseDirectory, + fileSystem, + context, + explicitSortOrder, + folderRef.Exclude + ); return new FolderRef(fullPath, pathRelativeToContainer, autoDiscoveredChildren, context, folderRef.Sort, folderRef.Exclude); } @@ -646,7 +784,8 @@ private static TableOfContents AutoDiscoverFolderFiles( IFileSystem fileSystem, string context, SortOrder? sortOrder, - IReadOnlyCollection? exclude) + IReadOnlyCollection? exclude + ) { var directoryPath = fileSystem.Path.Join(baseDirectory.FullName, folderPath); var directory = fileSystem.DirectoryInfo.New(directoryPath); @@ -655,9 +794,7 @@ private static TableOfContents AutoDiscoverFolderFiles( return []; // Find all .md files in the directory (not recursive) - var excludeSet = exclude is { Count: > 0 } - ? new HashSet(exclude, StringComparer.OrdinalIgnoreCase) - : null; + var excludeSet = exclude is { Count: > 0 } ? new HashSet(exclude, StringComparer.OrdinalIgnoreCase) : null; var mdFiles = fileSystem.Directory .GetFiles(directoryPath, "*.md") .Select(f => fileSystem.FileInfo.New(f)) @@ -743,7 +880,8 @@ static void CollectExcluded(IReadOnlyCollection items, Has IFileSystem fileSystem, string parentPath, string containerPath, - string context) + string context + ) { // Resolve the full path (same pattern as ResolveFolderRef) string fullPath; @@ -764,9 +902,7 @@ static void CollectExcluded(IReadOnlyCollection items, Has : $"{parentPath}/{listingRef.PathRelativeToDocumentationSet}"; } - var pathRelativeToContainer = string.IsNullOrEmpty(containerPath) - ? fullPath - : fullPath[(containerPath.Length + 1)..]; + var pathRelativeToContainer = string.IsNullOrEmpty(containerPath) ? fullPath : fullPath[(containerPath.Length + 1)..]; var options = listingRef.Options; var globPattern = options.Glob ?? "**/*.md"; @@ -778,14 +914,14 @@ static void CollectExcluded(IReadOnlyCollection items, Has if (hasNonMdGlob && string.IsNullOrEmpty(options.Extension)) { - collector.EmitError(context, - $"Listing '{fullPath}': glob '{globPattern}' may match non-.md files — 'extension:' is required to handle them."); + collector.EmitError( + context, + $"Listing '{fullPath}': glob '{globPattern}' may match non-.md files — 'extension:' is required to handle them." + ); } // Build exclude globs - var excludeGlobs = options.Exclude is { Count: > 0 } - ? options.Exclude.Select(Glob.Parse).ToArray() - : []; + var excludeGlobs = options.Exclude is { Count: > 0 } ? options.Exclude.Select(Glob.Parse).ToArray() : []; var listingDirAbsolute = fileSystem.Path.Join(baseDirectory.FullName, fullPath); var listingDir = fileSystem.DirectoryInfo.New(listingDirAbsolute); @@ -798,8 +934,7 @@ static void CollectExcluded(IReadOnlyCollection items, Has // Glob-match files. We enumerate all files in the folder tree and test against the pattern. var pattern = Glob.Parse(globPattern); - var allFiles = listingDir - .EnumerateFiles("*.*", SearchOption.AllDirectories) + var allFiles = listingDir.EnumerateFiles("*.*", SearchOption.AllDirectories) .Where(f => !f.Attributes.HasFlag(FileAttributes.Hidden) && !f.Attributes.HasFlag(FileAttributes.System)) .Where(f => !f.Directory!.Attributes.HasFlag(FileAttributes.Hidden) && !f.Directory!.Attributes.HasFlag(FileAttributes.System)) .Where(f => f.LinkTarget == null) @@ -832,19 +967,19 @@ static void CollectExcluded(IReadOnlyCollection items, Has } // Separate root/group index pages from content pages - var rootIndex = allFiles.FirstOrDefault(f => - f.FullName.Equals(fileSystem.Path.Join(listingDirAbsolute, "index.md"), StringComparison.OrdinalIgnoreCase)); - - var groupIndexFiles = allFiles - .Where(f => f.Name.Equals("index.md", StringComparison.OrdinalIgnoreCase) && f != rootIndex) - .ToDictionary( - f => Path.GetRelativePath(listingDirAbsolute, f.Directory!.FullName).Replace('\\', '/'), - f => f, - StringComparer.OrdinalIgnoreCase); - - var contentFiles = allFiles - .Where(f => f != rootIndex && !groupIndexFiles.ContainsValue(f)) - .ToList(); + var rootIndex = allFiles.FirstOrDefault( + f => f.FullName.Equals(fileSystem.Path.Join(listingDirAbsolute, "index.md"), StringComparison.OrdinalIgnoreCase) + ); + + var groupIndexFiles = allFiles.Where( + f => f.Name.Equals("index.md", StringComparison.OrdinalIgnoreCase) && f != rootIndex + ).ToDictionary( + f => Path.GetRelativePath(listingDirAbsolute, f.Directory!.FullName).Replace('\\', '/'), + f => f, + StringComparer.OrdinalIgnoreCase + ); + + var contentFiles = allFiles.Where(f => f != rootIndex && !groupIndexFiles.ContainsValue(f)).ToList(); // Sort content files contentFiles = SortOrderExtensions.TryParse(options.Sort, out var sortOrder) @@ -935,19 +1070,29 @@ static void CollectExcluded(IReadOnlyCollection items, Has /// /// Resolves a CrossLinkRef by recursively resolving children (though cross-links typically don't have children). /// - private static ITableOfContentsItem ResolveCrossLinkRef(IDiagnosticsCollector collector, + private static ITableOfContentsItem ResolveCrossLinkRef( + IDiagnosticsCollector collector, CrossLinkRef crossLinkRef, IDirectoryInfo baseDirectory, IFileSystem fileSystem, string parentPath, string containerPath, - string context) + string context + ) { if (crossLinkRef.Children.Count == 0) return new CrossLinkRef(crossLinkRef.CrossLinkUri, crossLinkRef.Title, crossLinkRef.Hidden, [], context); // For children of cross-links, the container remains the same - var resolvedChildren = ResolveTableOfContents(collector, crossLinkRef.Children, baseDirectory, fileSystem, parentPath, containerPath, context); + var resolvedChildren = ResolveTableOfContents( + collector, + crossLinkRef.Children, + baseDirectory, + fileSystem, + parentPath, + containerPath, + context + ); return new CrossLinkRef(crossLinkRef.CrossLinkUri, crossLinkRef.Title, crossLinkRef.Hidden, resolvedChildren, context); } diff --git a/src/Elastic.Documentation.Configuration/Toc/SiteNavigationFile.cs b/src/Elastic.Documentation.Configuration/Toc/SiteNavigationFile.cs index 00cca58d8f..c656b51a32 100644 --- a/src/Elastic.Documentation.Configuration/Toc/SiteNavigationFile.cs +++ b/src/Elastic.Documentation.Configuration/Toc/SiteNavigationFile.cs @@ -23,11 +23,7 @@ public interface ISiteNavigationEntry IReadOnlyCollection Children { get; } } -public record SiteSectionRef( - string Title, - string? ExternalUrl, - IReadOnlyCollection Children -) : ISiteNavigationEntry +public record SiteSectionRef(string Title, string? ExternalUrl, IReadOnlyCollection Children) : ISiteNavigationEntry { public bool IsExternal => ExternalUrl is not null; } @@ -140,8 +136,12 @@ public class SiteTableOfContents : List; /// When true, the resolved navigation node is marked as an island from the assembler side. /// OR-ed with any island: true the content set already declares — can only enable, never disable. /// -public record SiteTableOfContentsRef(Uri Source, string PathPrefix, IReadOnlyCollection Children, bool Island = false) - : ISiteNavigationEntry, ITableOfContentsItem +public record SiteTableOfContentsRef( + Uri Source, + string PathPrefix, + IReadOnlyCollection Children, + bool Island = false +) : ISiteNavigationEntry, ITableOfContentsItem { // For site-level TOC refs, the Path is the path prefix (where it will be mounted in the site) public string PathRelativeToDocumentationSet => PathPrefix; @@ -218,10 +218,11 @@ public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeseria if (dictionary.TryGetValue("section", out var sectionTitleVal) && sectionTitleVal is string sectionTitle) { - var externalUrl = dictionary.TryGetValue("external", out var extVal) && extVal is string e && !string.IsNullOrEmpty(e) ? e : null; - IReadOnlyCollection children = dictionary.TryGetValue("children", out var childrenObj) && childrenObj is List refs - ? refs - : []; + var externalUrl = dictionary.TryGetValue("external", out var extVal) && extVal is string e && !string.IsNullOrEmpty(e) + ? e + : null; + IReadOnlyCollection children = dictionary.TryGetValue("children", out var childrenObj) && + childrenObj is List refs ? refs : []; return new SiteSectionRef(sectionTitle, externalUrl, children); } @@ -232,28 +233,24 @@ public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeseria if (!Uri.TryCreate(uriString, UriKind.Absolute, out var source)) throw new InvalidOperationException($"Invalid TOC source: '{sourceString}' could not be parsed as a URI"); - var pathPrefix = dictionary.TryGetValue("path_prefix", out var pathValue) && pathValue is string path - ? path - : string.Empty; + var pathPrefix = dictionary.TryGetValue("path_prefix", out var pathValue) && pathValue is string path ? path : string.Empty; - IReadOnlyCollection children = dictionary.TryGetValue("children", out var childrenObj2) && childrenObj2 is List tocRefs - ? tocRefs - : []; + IReadOnlyCollection children = dictionary.TryGetValue("children", out var childrenObj2) && + childrenObj2 is List tocRefs ? tocRefs : []; - var island = dictionary.TryGetValue("island", out var islandObj) && islandObj is string islandStr - && bool.TryParse(islandStr, out var islandBool) && islandBool; + var island = dictionary.TryGetValue("island", out var islandObj) + && islandObj is string islandStr + && bool.TryParse(islandStr, out var islandBool) + && islandBool; return new SiteTableOfContentsRef(source, pathPrefix, children, island); } var keys = string.Join(", ", dictionary.Keys.Select(k => $"'{k}'")); - throw new YamlException( - $"toc entry has no 'toc:' key and will be ignored. " + - $"Found keys: {keys}. Check for typos."); + throw new YamlException($"toc entry has no 'toc:' key and will be ignored. " + $"Found keys: {keys}. Check for typos."); } - public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => - serializer.Invoke(value, type); + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => serializer.Invoke(value, type); } public class SiteTableOfContentsRefYamlConverter : IYamlTypeConverter @@ -307,26 +304,22 @@ public class SiteTableOfContentsRefYamlConverter : IYamlTypeConverter if (!Uri.TryCreate(uriString, UriKind.Absolute, out var source)) throw new InvalidOperationException($"Invalid TOC source: '{sourceString}' could not be parsed as a URI"); - var pathPrefix = dictionary.TryGetValue("path_prefix", out var pathValue) && pathValue is string path - ? path - : string.Empty; + var pathPrefix = dictionary.TryGetValue("path_prefix", out var pathValue) && pathValue is string path ? path : string.Empty; - IReadOnlyCollection children = dictionary.TryGetValue("children", out var childrenObj) && childrenObj is List tocRefs - ? tocRefs - : []; + IReadOnlyCollection children = dictionary.TryGetValue("children", out var childrenObj) && + childrenObj is List tocRefs ? tocRefs : []; - var island = dictionary.TryGetValue("island", out var islandObj) && islandObj is string islandStr - && bool.TryParse(islandStr, out var islandBool) && islandBool; + var island = dictionary.TryGetValue("island", out var islandObj) + && islandObj is string islandStr + && bool.TryParse(islandStr, out var islandBool) + && islandBool; return new SiteTableOfContentsRef(source, pathPrefix, children, island); } var keys = string.Join(", ", dictionary.Keys.Select(k => $"'{k}'")); - throw new YamlException( - $"toc entry has no 'toc:' key and will be ignored. " + - $"Found keys: {keys}. Check for typos."); + throw new YamlException($"toc entry has no 'toc:' key and will be ignored. " + $"Found keys: {keys}. Check for typos."); } - public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => - serializer.Invoke(value, type); + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => serializer.Invoke(value, type); } diff --git a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsItems.cs b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsItems.cs index 177af1f983..869718da61 100644 --- a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsItems.cs +++ b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsItems.cs @@ -118,21 +118,41 @@ public interface ITableOfContentsItem string Context { get; } } -public record FileRef(string PathRelativeToDocumentationSet, string PathRelativeToContainer, bool Hidden, IReadOnlyCollection Children, string Context) - : ITableOfContentsItem; +public record FileRef( + string PathRelativeToDocumentationSet, + string PathRelativeToContainer, + bool Hidden, + IReadOnlyCollection Children, + string Context +) : ITableOfContentsItem; -public record IndexFileRef(string PathRelativeToDocumentationSet, string PathRelativeToContainer, bool Hidden, IReadOnlyCollection Children, string Context) - : FileRef(PathRelativeToDocumentationSet, PathRelativeToContainer, Hidden, Children, Context); +public record IndexFileRef( + string PathRelativeToDocumentationSet, + string PathRelativeToContainer, + bool Hidden, + IReadOnlyCollection Children, + string Context +) : FileRef(PathRelativeToDocumentationSet, PathRelativeToContainer, Hidden, Children, Context); /// /// Represents a file reference created from a folder+file combination in YAML (e.g., "folder: path/to/dir, file: index.md"). /// Children of this file should resolve relative to the folder path, not the parent TOC path. /// -public record FolderIndexFileRef(string PathRelativeToDocumentationSet, string PathRelativeToContainer, bool Hidden, IReadOnlyCollection Children, string Context) - : IndexFileRef(PathRelativeToDocumentationSet, PathRelativeToContainer, Hidden, Children, Context); +public record FolderIndexFileRef( + string PathRelativeToDocumentationSet, + string PathRelativeToContainer, + bool Hidden, + IReadOnlyCollection Children, + string Context +) : IndexFileRef(PathRelativeToDocumentationSet, PathRelativeToContainer, Hidden, Children, Context); -public record CrossLinkRef(Uri CrossLinkUri, string? Title, bool Hidden, IReadOnlyCollection Children, string Context) - : ITableOfContentsItem +public record CrossLinkRef( + Uri CrossLinkUri, + string? Title, + bool Hidden, + IReadOnlyCollection Children, + string Context +) : ITableOfContentsItem { //TODO ensure we pass these to cross-links to // CrossLinks don't have a file system path, so we use the CrossLinkUri as the Path @@ -144,8 +164,14 @@ public record CrossLinkRef(Uri CrossLinkUri, string? Title, bool Hidden, IReadOn /// Raw YAML sort value, parsed and validated during resolution via . /// File names to exclude from auto-discovery (like "draft.md", "internal.md"). -public record FolderRef(string PathRelativeToDocumentationSet, string PathRelativeToContainer, IReadOnlyCollection Children, string Context, string? Sort = null, IReadOnlyCollection? Exclude = null) - : ITableOfContentsItem; +public record FolderRef( + string PathRelativeToDocumentationSet, + string PathRelativeToContainer, + IReadOnlyCollection Children, + string Context, + string? Sort = null, + IReadOnlyCollection? Exclude = null +) : ITableOfContentsItem; /// /// When true, this TOC renders as an island. Combines flags from both the inline diff --git a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs index 1bb409f32d..8ad7488c98 100644 --- a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs +++ b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs @@ -32,8 +32,7 @@ public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeseria return collection; } - public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => - serializer.Invoke(value, type); + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => serializer.Invoke(value, type); } public class TocItemYamlConverter : IYamlTypeConverter @@ -121,12 +120,16 @@ public class TocItemYamlConverter : IYamlTypeConverter { var glob = dictionary.TryGetValue("glob", out var globObj) && globObj is string globStr ? globStr : null; var extension = dictionary.TryGetValue("extension", out var extObj) && extObj is string extStr ? extStr : null; - var groups = dictionary.TryGetValue("groups", out var groupsObj) && groupsObj is string[] groupsArr ? (IReadOnlyCollection)groupsArr : null; + var groups = dictionary.TryGetValue("groups", out var groupsObj) && groupsObj is string[] groupsArr + ? (IReadOnlyCollection)groupsArr + : null; var listingVisual = ListingVisual.None; if (dictionary.TryGetValue("visual", out var visualObj) && visualObj is string visualStr) _ = ListingVisualExtensions.TryParse(visualStr, out listingVisual); - var island = dictionary.TryGetValue("island", out var islandObj) && islandObj is string islandStr - && bool.TryParse(islandStr, out var islandBool) && islandBool; + var island = dictionary.TryGetValue("island", out var islandObj) + && islandObj is string islandStr + && bool.TryParse(islandStr, out var islandBool) + && islandBool; // Reuse the already-captured sort and exclude variables from the outer scope var options = new ListingOptions(glob, sort, exclude, listingVisual, groups, extension, island); return new ListingRef(listing, listing, children, placeholderContext, options); @@ -138,16 +141,32 @@ public class TocItemYamlConverter : IYamlTypeConverter { var supplementalFolder = dictionary.TryGetValue("folder", out var f) && f is string fStr ? fStr : null; var title = dictionary.TryGetValue("title", out var t) && t is string titleStr ? titleStr : null; - var navigationTitle = dictionary.TryGetValue("navigation_title", out var nt) && nt is string navigationTitleStr ? navigationTitleStr : null; + var navigationTitle = dictionary.TryGetValue("navigation_title", out var nt) && nt is string navigationTitleStr + ? navigationTitleStr + : null; var appliesTo = dictionary.TryGetValue("applies_to", out var at) && at is ApplicableTo a ? a : null; - return new CliReferenceRef(cliSchema, supplementalFolder, title, navigationTitle, cliSchema, cliSchema, placeholderContext, children, appliesTo); + return new CliReferenceRef( + cliSchema, + supplementalFolder, + title, + navigationTitle, + cliSchema, + cliSchema, + placeholderContext, + children, + appliesTo + ); } // Check for folder+file combination (e.g., folder: getting-started, file: getting-started.md) // This represents a folder with a specific index file // The file becomes a child of the folder (as FolderIndexFileRef), and user-specified children follow - if (dictionary.TryGetValue("folder", out var folderPath) && folderPath is string folder && - dictionary.TryGetValue("file", out var filePath) && filePath is string file) + if ( + dictionary.TryGetValue("folder", out var folderPath) + && folderPath is string folder + && dictionary.TryGetValue("file", out var filePath) + && filePath is string file + ) { // Create the index file reference (FolderIndexFileRef to mark it as the folder's index) // Store ONLY the file name - the folder path will be prepended during resolution @@ -164,11 +183,24 @@ public class TocItemYamlConverter : IYamlTypeConverter // PathRelativeToContainer will be set during resolution return new FolderRef(folder, folder, folderChildren, placeholderContext, sort, exclude); } - if (dictionary.TryGetValue("detection_rules", out var detectionRulesObj) && detectionRulesObj is string[] detectionRulesFolders && - dictionary.TryGetValue("file", out var detectionRulesFilePath) && detectionRulesFilePath is string detectionRulesFile) + if ( + dictionary.TryGetValue("detection_rules", out var detectionRulesObj) + && detectionRulesObj is string[] detectionRulesFolders + && dictionary.TryGetValue("file", out var detectionRulesFilePath) + && detectionRulesFilePath is string detectionRulesFile + ) { - var deprecatedFile = dictionary.TryGetValue("deprecated_file", out var deprecatedFileObj) && deprecatedFileObj is string df ? df : null; - return new DetectionRuleOverviewRef(detectionRulesFile, detectionRulesFile, detectionRulesFolders, children, placeholderContext, deprecatedFile); + var deprecatedFile = dictionary.TryGetValue("deprecated_file", out var deprecatedFileObj) && deprecatedFileObj is string df + ? df + : null; + return new DetectionRuleOverviewRef( + detectionRulesFile, + detectionRulesFile, + detectionRulesFolders, + children, + placeholderContext, + deprecatedFile + ); } // Check for file reference (file: or hidden:) @@ -181,7 +213,9 @@ public class TocItemYamlConverter : IYamlTypeConverter } if (dictionary.TryGetValue("hidden", out var hiddenPath) && hiddenPath is string p) - return p == "index.md" ? new IndexFileRef(p, p, true, children, placeholderContext) : new FileRef(p, p, true, children, placeholderContext); + return p == "index.md" + ? new IndexFileRef(p, p, true, children, placeholderContext) + : new FileRef(p, p, true, children, placeholderContext); // Check for crosslink reference if (dictionary.TryGetValue("crosslink", out var crosslink) && crosslink is string crosslinkStr) @@ -200,8 +234,10 @@ public class TocItemYamlConverter : IYamlTypeConverter // PathRelativeToContainer will be set during resolution if (dictionary.TryGetValue("toc", out var tocPath) && tocPath is string source) { - var island = dictionary.TryGetValue("island", out var islandObj) && islandObj is string islandStr - && bool.TryParse(islandStr, out var islandBool) && islandBool; + var island = dictionary.TryGetValue("island", out var islandObj) + && islandObj is string islandStr + && bool.TryParse(islandStr, out var islandBool) + && islandBool; return new IsolatedTableOfContentsRef(source, source, children, placeholderContext, island); } @@ -220,6 +256,5 @@ private static IReadOnlyCollection GetChildren(Dictionary< return []; } - public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => - serializer.Invoke(value, type); + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => serializer.Invoke(value, type); } diff --git a/src/Elastic.Documentation.Configuration/Versions/VersionsConfigurationExtensions.cs b/src/Elastic.Documentation.Configuration/Versions/VersionsConfigurationExtensions.cs index 3458a686ea..6d64514adc 100644 --- a/src/Elastic.Documentation.Configuration/Versions/VersionsConfigurationExtensions.cs +++ b/src/Elastic.Documentation.Configuration/Versions/VersionsConfigurationExtensions.cs @@ -17,12 +17,14 @@ public static VersionsConfiguration CreateVersionConfiguration(this Configuratio var versions = versionsDto.VersioningSystems.ToDictionary( kvp => ToVersioningSystemId(kvp.Key), - kvp => new VersioningSystem - { - Id = ToVersioningSystemId(kvp.Key), - Base = ToSemVersion(kvp.Value.Base), - Current = ToSemVersion(kvp.Value.Current) - }); + kvp => + new VersioningSystem + { + Id = ToVersioningSystemId(kvp.Key), + Base = ToSemVersion(kvp.Value.Base), + Current = ToSemVersion(kvp.Value.Current) + } + ); var config = new VersionsConfiguration { VersioningSystems = versions }; return config; @@ -64,4 +66,3 @@ internal sealed record VersioningSystemDto [YamlMember(Alias = "current")] public string Current { get; set; } = string.Empty; } - diff --git a/src/Elastic.Documentation.Configuration/YamlStreamReader.cs b/src/Elastic.Documentation.Configuration/YamlStreamReader.cs index 9148e016d3..a7d07a4f1d 100644 --- a/src/Elastic.Documentation.Configuration/YamlStreamReader.cs +++ b/src/Elastic.Documentation.Configuration/YamlStreamReader.cs @@ -170,8 +170,7 @@ public void EmitError(string message, YamlNode? node) => public void EmitWarning(string message, YamlNode? node) => EmitWarning(message, node?.Start, node?.End, (node as YamlScalarNode)?.Value?.Length); - public void EmitError(string message, Exception e) => - Collector.EmitError(Source.FullName, message, e); + public void EmitError(string message, Exception e) => Collector.EmitError(Source.FullName, message, e); private void EmitError(string message, Mark? start = null, Mark? end = null, int? length = null) { diff --git a/src/Elastic.Documentation.Indexing/AiEnrichmentDeadline.cs b/src/Elastic.Documentation.Indexing/AiEnrichmentDeadline.cs index 0bf4d34724..aa6b167c0e 100644 --- a/src/Elastic.Documentation.Indexing/AiEnrichmentDeadline.cs +++ b/src/Elastic.Documentation.Indexing/AiEnrichmentDeadline.cs @@ -31,9 +31,7 @@ private AiEnrichmentDeadline(CancellationTokenSource? timeoutCts, CancellationTo public static AiEnrichmentDeadline Create(TimeSpan? maxWallClock, CancellationToken ct) { var timeoutCts = maxWallClock is { } d ? new CancellationTokenSource(d) : null; - var linkedCts = timeoutCts is not null - ? CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token) - : null; + var linkedCts = timeoutCts is not null ? CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token) : null; return new AiEnrichmentDeadline(timeoutCts, linkedCts, linkedCts?.Token ?? ct); } diff --git a/src/Elastic.Documentation.Indexing/AiEnrichmentRunner.cs b/src/Elastic.Documentation.Indexing/AiEnrichmentRunner.cs index 8e0f0febf8..9e0e62c1f8 100644 --- a/src/Elastic.Documentation.Indexing/AiEnrichmentRunner.cs +++ b/src/Elastic.Documentation.Indexing/AiEnrichmentRunner.cs @@ -32,8 +32,7 @@ public static async Task RunPostSyncAsync( ILogger logger, CancellationToken ct, Action? onProgress = null - ) - where TDoc : class + ) where TDoc : class { var alias = context.SecondaryWriteAlias; if (string.IsNullOrEmpty(alias)) @@ -47,7 +46,8 @@ public static async Task RunPostSyncAsync( logger.LogInformation( "Starting post-sync AI enrichment for {Alias} (max {MaxDocs} documents per run)...", alias, - budget.EffectiveMaxDocs); + budget.EffectiveMaxDocs + ); var options = new AiEnrichmentOptions { @@ -64,7 +64,8 @@ public static async Task RunPostSyncAsync( p.Enriched, p.Failed, p.TotalCandidates, - p.Message is not null ? $" — {p.Message}" : ""); + p.Message is not null ? $" — {p.Message}" : "" + ); onProgress?.Invoke(p); } } diff --git a/src/Elastic.Documentation.LegacyDocs/BloomFilter.cs b/src/Elastic.Documentation.LegacyDocs/BloomFilter.cs index 9e3631f906..e011c924b9 100644 --- a/src/Elastic.Documentation.LegacyDocs/BloomFilter.cs +++ b/src/Elastic.Documentation.LegacyDocs/BloomFilter.cs @@ -172,7 +172,6 @@ public static BloomFilter Load(Stream stream) return filter; } - // --- Optimal Parameter Calculation --- /// diff --git a/src/Elastic.Documentation.LegacyDocs/LegacyPageService.cs b/src/Elastic.Documentation.LegacyDocs/LegacyPageService.cs index e297659296..2d0580c8d0 100644 --- a/src/Elastic.Documentation.LegacyDocs/LegacyPageService.cs +++ b/src/Elastic.Documentation.LegacyDocs/LegacyPageService.cs @@ -29,9 +29,11 @@ public bool PathExists(string path, bool logResult = false) private static BloomFilter LoadBloomFilter() { var assembly = typeof(LegacyPageService).Assembly; - using var stream = assembly.GetManifestResourceStream(ResourceName) ?? throw new FileNotFoundException( - $"Embedded resource '{ResourceName}' not found in assembly '{assembly.FullName}'. " + - "Ensure the Build Action for 'legacy-pages.bloom.bin' is 'Embedded Resource' and the path/name is correct."); + using var stream = assembly.GetManifestResourceStream(ResourceName) ?? + throw new FileNotFoundException( + $"Embedded resource '{ResourceName}' not found in assembly '{assembly.FullName}'. " + + "Ensure the Build Action for 'legacy-pages.bloom.bin' is 'Embedded Resource' and the path/name is correct." + ); return BloomFilter.Load(stream); } diff --git a/src/Elastic.Documentation.LegacyDocs/PageLegacyUrlMapper.cs b/src/Elastic.Documentation.LegacyDocs/PageLegacyUrlMapper.cs index d50fdce3eb..cc752e56c8 100644 --- a/src/Elastic.Documentation.LegacyDocs/PageLegacyUrlMapper.cs +++ b/src/Elastic.Documentation.LegacyDocs/PageLegacyUrlMapper.cs @@ -13,14 +13,18 @@ public record PageLegacyUrlMapper : ILegacyUrlMapper private string DefaultVersion { get; } private LegacyUrlMappingConfiguration LegacyUrlMappings { get; } - public PageLegacyUrlMapper(LegacyPageService legacyPageService, VersionsConfiguration versions, LegacyUrlMappingConfiguration legacyUrlMappings) + public PageLegacyUrlMapper( + LegacyPageService legacyPageService, + VersionsConfiguration versions, + LegacyUrlMappingConfiguration legacyUrlMappings + ) { LegacyPageService = legacyPageService; - DefaultVersion = $"{versions.VersioningSystems[VersioningSystemId.Stack].Base.Major}.{versions.VersioningSystems[VersioningSystemId.Stack].Base.Minor}"; + DefaultVersion = + $"{versions.VersioningSystems[VersioningSystemId.Stack].Base.Major}.{versions.VersioningSystems[VersioningSystemId.Stack].Base.Minor}"; LegacyUrlMappings = legacyUrlMappings; } - public IReadOnlyCollection? MapLegacyUrl(IReadOnlyCollection? mappedPages) { if (mappedPages is null || mappedPages.Count == 0) @@ -28,8 +32,19 @@ public PageLegacyUrlMapper(LegacyPageService legacyPageService, VersionsConfigur var mappedPage = mappedPages.First(); - if (LegacyUrlMappings.Mappings.FirstOrDefault(x => mappedPage.Contains(x.BaseUrl, StringComparison.OrdinalIgnoreCase)) is not { } legacyMappingMatch) - return [new LegacyPageMapping(LegacyUrlMappings.Mappings.First(x => x.Product.Id.Equals("elastic-stack", StringComparison.OrdinalIgnoreCase)).Product, mappedPages.FirstOrDefault() ?? string.Empty, DefaultVersion, false)]; + if ( + LegacyUrlMappings.Mappings.FirstOrDefault( + x => mappedPage.Contains(x.BaseUrl, StringComparison.OrdinalIgnoreCase) + ) is not { } legacyMappingMatch + ) + return [ + new LegacyPageMapping( + LegacyUrlMappings.Mappings.First(x => x.Product.Id.Equals("elastic-stack", StringComparison.OrdinalIgnoreCase)).Product, + mappedPages.FirstOrDefault() ?? string.Empty, + DefaultVersion, + false + ) + ]; var allVersions = new List(); diff --git a/src/Elastic.Documentation.LegacyDocs/PagesProvider.cs b/src/Elastic.Documentation.LegacyDocs/PagesProvider.cs index 11b87f9718..7528ce3d95 100644 --- a/src/Elastic.Documentation.LegacyDocs/PagesProvider.cs +++ b/src/Elastic.Documentation.LegacyDocs/PagesProvider.cs @@ -16,10 +16,9 @@ public interface IPagesProvider public class LocalPagesProvider(string gitRepositoryPath) : IPagesProvider { public IEnumerable GetPages() => - Directory.EnumerateFiles(Path.Join(gitRepositoryPath, "html", "en"), "*.html", SearchOption.AllDirectories) - .Select(i => - { - var relativePath = "/guide/" + Path.GetRelativePath(Path.Join(gitRepositoryPath, "html"), i).Replace('\\', '/'); - return relativePath; - }); + Directory.EnumerateFiles(Path.Join(gitRepositoryPath, "html", "en"), "*.html", SearchOption.AllDirectories).Select(i => + { + var relativePath = "/guide/" + Path.GetRelativePath(Path.Join(gitRepositoryPath, "html"), i).Replace('\\', '/'); + return relativePath; + }); } diff --git a/src/Elastic.Documentation.LinkIndex/GitLinkIndexReader.cs b/src/Elastic.Documentation.LinkIndex/GitLinkIndexReader.cs index 205c6a6432..0b1248f1d5 100644 --- a/src/Elastic.Documentation.LinkIndex/GitLinkIndexReader.cs +++ b/src/Elastic.Documentation.LinkIndex/GitLinkIndexReader.cs @@ -18,9 +18,7 @@ namespace Elastic.Documentation.LinkIndex; public class GitLinkIndexReader : ILinkIndexReader, IDisposable { private const string LinkIndexOrigin = "elastic/codex-link-index"; - private static readonly string CloneDirectory = Path.Join( - Paths.ApplicationData.FullName, - "codex-link-index"); + private static readonly string CloneDirectory = Path.Join(Paths.ApplicationData.FullName, "codex-link-index"); private readonly string _environment; private readonly IFileSystem _fileSystem; @@ -31,7 +29,10 @@ public class GitLinkIndexReader : ILinkIndexReader, IDisposable public GitLinkIndexReader(string environment, ApplicationDataFileSystem? fileSystem = null, bool skipFetch = false) { if (string.IsNullOrWhiteSpace(environment)) - throw new ArgumentException("Environment must be specified in the codex configuration (e.g., 'internal', 'security').", nameof(environment)); + throw new ArgumentException( + "Environment must be specified in the codex configuration (e.g., 'internal', 'security').", + nameof(environment) + ); _environment = environment; _fileSystem = fileSystem ?? new ApplicationDataFileSystem(); @@ -64,7 +65,9 @@ public async Task GetRegistry(Cancel cancellationToken = default) EnsureSafeRelativePath(_environment, nameof(_environment)); var registryPath = Path.Join(CloneDirectory, _environment, "link-index.json"); if (!_fileSystem.File.Exists(registryPath)) - throw new FileNotFoundException($"Link index registry not found at {registryPath}. Ensure the codex-link-index repository has {_environment}/link-index.json."); + throw new FileNotFoundException( + $"Link index registry not found at {registryPath}. Ensure the codex-link-index repository has {_environment}/link-index.json." + ); var json = await _fileSystem.File.ReadAllTextAsync(registryPath, cancellationToken); return LinkRegistry.Deserialize(json); @@ -96,7 +99,8 @@ private async Task EnsureCloneAsync(Cancel cancellationToken) { if (!_fileSystem.Directory.Exists(gitDir)) throw new InvalidOperationException( - $"Codex link index not found at {CloneDirectory}. Run 'docs-builder codex clone' first."); + $"Codex link index not found at {CloneDirectory}. Run 'docs-builder codex clone' first." + ); _ensuredClone = true; return; } diff --git a/src/Elastic.Documentation.LinkIndex/LinkIndexReader.cs b/src/Elastic.Documentation.LinkIndex/LinkIndexReader.cs index b3a51192f3..eca7bfa7c7 100644 --- a/src/Elastic.Documentation.LinkIndex/LinkIndexReader.cs +++ b/src/Elastic.Documentation.LinkIndex/LinkIndexReader.cs @@ -9,7 +9,11 @@ namespace Elastic.Documentation.LinkIndex; -public class Aws3LinkIndexReader(IAmazonS3 s3Client, string bucketName = "elastic-docs-link-index", string registryKey = "link-index.json") : ILinkIndexReader +public class Aws3LinkIndexReader( + IAmazonS3 s3Client, + string bucketName = "elastic-docs-link-index", + string registryKey = "link-index.json" +) : ILinkIndexReader { // @@ -19,21 +23,14 @@ public class Aws3LinkIndexReader(IAmazonS3 s3Client, string bucketName = "elasti public static Aws3LinkIndexReader CreateAnonymous() { var credentials = new AnonymousAWSCredentials(); - var config = new AmazonS3Config - { - RegionEndpoint = Amazon.RegionEndpoint.USEast2 - }; + var config = new AmazonS3Config { RegionEndpoint = Amazon.RegionEndpoint.USEast2 }; var s3Client = new AmazonS3Client(credentials, config); return new AwsS3LinkIndexReaderWriter(s3Client); } public async Task GetRegistry(Cancel cancellationToken = default) { - var getObjectRequest = new GetObjectRequest - { - BucketName = bucketName, - Key = registryKey - }; + var getObjectRequest = new GetObjectRequest { BucketName = bucketName, Key = registryKey }; var getObjectResponse = await s3Client.GetObjectAsync(getObjectRequest, cancellationToken); await using var stream = getObjectResponse.ResponseStream; var linkIndex = LinkRegistry.Deserialize(stream); @@ -41,11 +38,7 @@ public async Task GetRegistry(Cancel cancellationToken = default) } public async Task GetRepositoryLinks(string key, Cancel cancellationToken) { - var getObjectRequest = new GetObjectRequest - { - BucketName = bucketName, - Key = key - }; + var getObjectRequest = new GetObjectRequest { BucketName = bucketName, Key = key }; var getObjectResponse = await s3Client.GetObjectAsync(getObjectRequest, cancellationToken); await using var stream = getObjectResponse.ResponseStream; return RepositoryLinks.Deserialize(stream); diff --git a/src/Elastic.Documentation.LinkIndex/LinkIndexReaderWriter.cs b/src/Elastic.Documentation.LinkIndex/LinkIndexReaderWriter.cs index 358bb309db..9560f53bdc 100644 --- a/src/Elastic.Documentation.LinkIndex/LinkIndexReaderWriter.cs +++ b/src/Elastic.Documentation.LinkIndex/LinkIndexReaderWriter.cs @@ -31,7 +31,8 @@ public async Task SaveRegistry(LinkRegistry registry, Cancel cancellationToken = Key = _registryKey, ContentBody = json, ContentType = "application/json", - IfMatch = registry.ETag // Only update if the ETag matches. Meaning the object has not been changed in the meantime. + IfMatch = + registry.ETag // Only update if the ETag matches. Meaning the object has not been changed in the meantime. }; var putResponse = await _s3Client.PutObjectAsync(putObjectRequest, cancellationToken); if (putResponse.HttpStatusCode != HttpStatusCode.OK) diff --git a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs index 1baf0e7562..648bd59777 100644 --- a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs +++ b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs @@ -59,7 +59,11 @@ public record FetchedCrossLinks }; } -public abstract class CrossLinkFetcher(ILoggerFactory logFactory, ILinkIndexReader linkIndexProvider, ApplicationDataFileSystem? fileSystem = null) : IDisposable +public abstract class CrossLinkFetcher( + ILoggerFactory logFactory, + ILinkIndexReader linkIndexProvider, + ApplicationDataFileSystem? fileSystem = null +) : IDisposable { protected ILogger Logger { get; } = logFactory.CreateLogger(nameof(CrossLinkFetcher)); private readonly IFileSystem _fileSystem = fileSystem ?? new ApplicationDataFileSystem(); @@ -94,13 +98,15 @@ protected async Task GetLinkIndexEntry(string repository, Can return GetNextContentSourceLinkIndexEntry(repositoryLinks, repository); } - protected static LinkRegistryEntry GetNextContentSourceLinkIndexEntry(IDictionary repositoryLinks, string repository) + protected static LinkRegistryEntry GetNextContentSourceLinkIndexEntry( + IDictionary repositoryLinks, + string repository + ) { - var linkIndexEntry = - (repositoryLinks.TryGetValue("main", out var link) - ? link - : repositoryLinks.TryGetValue("master", out link) ? link : null) - ?? throw new Exception($"Repository {repository} found in link index, but no main or master branch found"); + var linkIndexEntry = (repositoryLinks.TryGetValue("main", out var link) + ? link + : repositoryLinks.TryGetValue("master", out link) ? link : null) ?? + throw new Exception($"Repository {repository} found in link index, but no main or master branch found"); return linkIndexEntry; } @@ -129,7 +135,8 @@ protected async Task FetchLinkIndexEntryFromReader( ILinkIndexReader reader, string repository, LinkRegistryEntry linkRegistryEntry, - Cancel ctx) + Cancel ctx + ) { var linkReference = await TryGetCachedLinkReference(repository, linkRegistryEntry); if (linkReference is not null) @@ -151,7 +158,8 @@ protected static async Task FetchCrossLinksFromReader( ILinkIndexReader reader, string repository, CrossLinkFetcher fetcher, - Cancel ctx) + Cancel ctx + ) { var linkIndex = await reader.GetRegistry(ctx); if (!linkIndex.Repositories.TryGetValue(repository, out var repositoryLinks)) @@ -202,7 +210,6 @@ private void WriteLinksJsonCachedFile(string repository, LinkRegistryEntry linkR } } return null; - } public void Dispose() diff --git a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkResolver.cs b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkResolver.cs index d329393b40..8fb168efad 100644 --- a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkResolver.cs +++ b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkResolver.cs @@ -40,7 +40,6 @@ public bool TryResolve(Action errorEmitter, Uri crossLinkUri, [NotNullWh public bool IsDeclaredCrossLinkScheme(string scheme) => false; private NoopCrossLinkResolver() { } - } public class CrossLinkResolver(FetchedCrossLinks crossLinks, IUriEnvironmentResolver? uriResolver = null) : ICrossLinkResolver @@ -58,10 +57,7 @@ public FetchedCrossLinks UpdateLinkReference(string repository, RepositoryLinks { var dictionary = _crossLinks.LinkReferences.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); dictionary[repository] = repositoryLinks; - _crossLinks = _crossLinks with - { - LinkReferences = dictionary.ToFrozenDictionary() - }; + _crossLinks = _crossLinks with { LinkReferences = dictionary.ToFrozenDictionary() }; return _crossLinks; } @@ -90,7 +86,9 @@ public static bool TryResolve( return true; } - errorEmitter($"'{crossLinkUri.Scheme}' was not found in the cross link index. Ensure it is listed under 'cross_links' in your docset.yml"); + errorEmitter( + $"'{crossLinkUri.Scheme}' was not found in the cross link index. Ensure it is listed under 'cross_links' in your docset.yml" + ); return false; } @@ -98,14 +96,24 @@ public static bool TryResolve( if (string.IsNullOrEmpty(originalLookupPath) && crossLinkUri.Host.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) originalLookupPath = crossLinkUri.Host; - if (sourceLinkReference.Redirects is not null && sourceLinkReference.Redirects.TryGetValue(originalLookupPath, out var redirectRule)) - return ResolveRedirect(errorEmitter, uriResolver, crossLinkUri, redirectRule, originalLookupPath, fetchedCrossLinks, out resolvedUri); + if ( + sourceLinkReference.Redirects is not null && sourceLinkReference.Redirects.TryGetValue(originalLookupPath, out var redirectRule) + ) + return ResolveRedirect( + errorEmitter, + uriResolver, + crossLinkUri, + redirectRule, + originalLookupPath, + fetchedCrossLinks, + out resolvedUri + ); if (sourceLinkReference.Links.TryGetValue(originalLookupPath, out var directLinkMetadata)) return ResolveDirectLink(errorEmitter, uriResolver, crossLinkUri, originalLookupPath, directLinkMetadata, out resolvedUri); - var registryUrl = fetchedCrossLinks.RegistryUrlsByRepository?.GetValueOrDefault(crossLinkUri.Scheme) - ?? "https://elastic-docs-link-index.s3.us-east-2.amazonaws.com"; + var registryUrl = fetchedCrossLinks.RegistryUrlsByRepository?.GetValueOrDefault(crossLinkUri.Scheme) ?? + "https://elastic-docs-link-index.s3.us-east-2.amazonaws.com"; var baseUrl = GetLinksJsonBaseUrl(registryUrl); var linksJson = fetchedCrossLinks.LinkIndexEntries.TryGetValue(crossLinkUri.Scheme, out var indexEntry) ? $"{baseUrl}/{indexEntry.Path}" @@ -116,12 +124,14 @@ public static bool TryResolve( return false; } - private static bool ResolveDirectLink(Action errorEmitter, + private static bool ResolveDirectLink( + Action errorEmitter, IUriEnvironmentResolver uriResolver, Uri crossLinkUri, string lookupPath, LinkMetadata linkMetadata, - [NotNullWhen(true)] out Uri? resolvedUri) + [NotNullWhen(true)] out Uri? resolvedUri + ) { resolvedUri = null; var lookupFragment = crossLinkUri.Fragment; @@ -150,7 +160,8 @@ private static bool ResolveRedirect( LinkRedirect redirectRule, string originalLookupPath, FetchedCrossLinks fetchedCrossLinks, - [NotNullWhen(true)] out Uri? resolvedUri) + [NotNullWhen(true)] out Uri? resolvedUri + ) { resolvedUri = null; var originalFragment = originalCrossLinkUri.Fragment.TrimStart('#'); @@ -166,9 +177,25 @@ private static bool ResolveRedirect( continue; if (subRule.Anchors.TryGetValue("!", out _)) - return FinalizeRedirect(errorEmitter, uriResolver, originalCrossLinkUri, subRule.To, null, fetchedCrossLinks, out resolvedUri); + return FinalizeRedirect( + errorEmitter, + uriResolver, + originalCrossLinkUri, + subRule.To, + null, + fetchedCrossLinks, + out resolvedUri + ); if (subRule.Anchors.TryGetValue(originalFragment, out var mappedAnchor)) - return FinalizeRedirect(errorEmitter, uriResolver, originalCrossLinkUri, subRule.To, mappedAnchor, fetchedCrossLinks, out resolvedUri); + return FinalizeRedirect( + errorEmitter, + uriResolver, + originalCrossLinkUri, + subRule.To, + mappedAnchor, + fetchedCrossLinks, + out resolvedUri + ); } } @@ -184,14 +211,32 @@ private static bool ResolveRedirect( finalTargetFragment = originalFragment; else { - errorEmitter($"Redirect rule for '{originalLookupPath}' in '{originalCrossLinkUri.Scheme}' found, but top-level rule did not handle anchor '#{originalFragment}'."); + errorEmitter( + $"Redirect rule for '{originalLookupPath}' in '{originalCrossLinkUri.Scheme}' found, but top-level rule did not handle anchor '#{originalFragment}'." + ); return false; } } return string.IsNullOrEmpty(redirectRule.To) - ? FinalizeRedirect(errorEmitter, uriResolver, originalCrossLinkUri, originalLookupPath, finalTargetFragment, fetchedCrossLinks, out resolvedUri) - : FinalizeRedirect(errorEmitter, uriResolver, originalCrossLinkUri, redirectRule.To, finalTargetFragment, fetchedCrossLinks, out resolvedUri); + ? FinalizeRedirect( + errorEmitter, + uriResolver, + originalCrossLinkUri, + originalLookupPath, + finalTargetFragment, + fetchedCrossLinks, + out resolvedUri + ) + : FinalizeRedirect( + errorEmitter, + uriResolver, + originalCrossLinkUri, + redirectRule.To, + finalTargetFragment, + fetchedCrossLinks, + out resolvedUri + ); } private static bool FinalizeRedirect( @@ -201,12 +246,17 @@ private static bool FinalizeRedirect( string redirectToPath, string? targetFragment, FetchedCrossLinks fetchedCrossLinks, - [NotNullWhen(true)] out Uri? resolvedUri) + [NotNullWhen(true)] out Uri? resolvedUri + ) { resolvedUri = null; string finalPathForResolver; - if (Uri.TryCreate(redirectToPath, UriKind.Absolute, out var targetCrossUri) && targetCrossUri.Scheme != "http" && targetCrossUri.Scheme != "https") + if ( + Uri.TryCreate(redirectToPath, UriKind.Absolute, out var targetCrossUri) + && targetCrossUri.Scheme != "http" + && targetCrossUri.Scheme != "https" + ) { var lookupPath = $"{targetCrossUri.Host}/{targetCrossUri.AbsolutePath.TrimStart('/')}"; finalPathForResolver = ToTargetUrlPath(lookupPath); @@ -216,13 +266,17 @@ private static bool FinalizeRedirect( if (!fetchedCrossLinks.LinkReferences.TryGetValue(targetCrossUri.Scheme, out var targetLinkReference)) { - errorEmitter($"Redirect target '{redirectToPath}' points to repository '{targetCrossUri.Scheme}' for which no links.json was found."); + errorEmitter( + $"Redirect target '{redirectToPath}' points to repository '{targetCrossUri.Scheme}' for which no links.json was found." + ); return false; } if (!targetLinkReference.Links.ContainsKey(lookupPath)) { - errorEmitter($"Redirect target '{redirectToPath}' points to file '{lookupPath}' which was not found in repository '{targetCrossUri.Scheme}'s links.json."); + errorEmitter( + $"Redirect target '{redirectToPath}' points to file '{lookupPath}' which was not found in repository '{targetCrossUri.Scheme}'s links.json." + ); return false; } @@ -267,9 +321,11 @@ private static string GetLinksJsonBaseUrl(string registryUrl) /// private static string BuildFallbackLinksJsonUrl(string baseUrl, string scheme, FetchedCrossLinks fetchedCrossLinks) { - if (fetchedCrossLinks.RegistryByRepository is not null + if ( + fetchedCrossLinks.RegistryByRepository is not null && fetchedCrossLinks.RegistryByRepository.TryGetValue(scheme, out var registry) - && registry != DocSetRegistry.Public) + && registry != DocSetRegistry.Public + ) { return $"{baseUrl}/{registry.ToStringFast(true)}/elastic/{scheme}/links.json"; } diff --git a/src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs b/src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs index ec8ab8aa46..e6955381a8 100644 --- a/src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs +++ b/src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs @@ -15,8 +15,8 @@ public class DocSetConfigurationCrossLinkFetcher( ILoggerFactory logFactory, ConfigurationFile configuration, ILinkIndexReader? linkIndexProvider = null, - ILinkIndexReader? codexLinkIndexReader = null) - : CrossLinkFetcher(logFactory, linkIndexProvider ?? Aws3LinkIndexReader.CreateAnonymous()) + ILinkIndexReader? codexLinkIndexReader = null +) : CrossLinkFetcher(logFactory, linkIndexProvider ?? Aws3LinkIndexReader.CreateAnonymous()) { private readonly ILogger _logger = logFactory.CreateLogger(nameof(DocSetConfigurationCrossLinkFetcher)); private readonly ILinkIndexReader? _codexReader = codexLinkIndexReader; @@ -65,24 +65,31 @@ public override async Task FetchCrossLinks(Cancel ctx) catch (Exception ex) { hadFetchFailures = true; - _logger.LogWarning(ex, "Error fetching link data for repository '{Repository}'. Cross-links to this repository may not resolve correctly.", entry.Repository); + _logger.LogWarning( + ex, + "Error fetching link data for repository '{Repository}'. Cross-links to this repository may not resolve correctly.", + entry.Repository + ); _ = registryUrlsByRepository.TryAdd(entry.Repository, reader.RegistryUrl); if (!linkReferences.ContainsKey(entry.Repository)) { - linkReferences.Add(entry.Repository, new RepositoryLinks - { - Links = [], - Origin = new GitCheckoutInformation + linkReferences.Add( + entry.Repository, + new RepositoryLinks { - Branch = "main", - RepositoryName = entry.Repository, - Remote = "origin", - Ref = "refs/heads/main" - }, - UrlPathPrefix = "", - CrossLinks = [] - }); + Links = [], + Origin = new GitCheckoutInformation + { + Branch = "main", + RepositoryName = entry.Repository, + Remote = "origin", + Ref = "refs/heads/main" + }, + UrlPathPrefix = "", + CrossLinks = [] + } + ); } } } diff --git a/src/Elastic.Documentation.Links/CrossLinks/IUriEnvironmentResolver.cs b/src/Elastic.Documentation.Links/CrossLinks/IUriEnvironmentResolver.cs index 62ab1bf491..5631fc61e6 100644 --- a/src/Elastic.Documentation.Links/CrossLinks/IUriEnvironmentResolver.cs +++ b/src/Elastic.Documentation.Links/CrossLinks/IUriEnvironmentResolver.cs @@ -18,12 +18,11 @@ public class IsolatedBuildEnvironmentUriResolver : IUriEnvironmentResolver public Uri Resolve(Uri crossLinkUri, string path) => new(BaseUri, $"elastic/{crossLinkUri.Scheme}/tree/{GetBranch(crossLinkUri)}/{path}"); - public static string GetBranch(Uri crossLinkUri) => - crossLinkUri.Scheme switch - { - "cloud" => "master", - _ => "main" - }; + public static string GetBranch(Uri crossLinkUri) => crossLinkUri.Scheme switch + { + "cloud" => "master", + _ => "main" + }; } /// diff --git a/src/Elastic.Documentation.Links/InboundLinks/LinkIndexCrossLinkFetcher.cs b/src/Elastic.Documentation.Links/InboundLinks/LinkIndexCrossLinkFetcher.cs index 8a4a8bf703..45c15bb496 100644 --- a/src/Elastic.Documentation.Links/InboundLinks/LinkIndexCrossLinkFetcher.cs +++ b/src/Elastic.Documentation.Links/InboundLinks/LinkIndexCrossLinkFetcher.cs @@ -11,7 +11,10 @@ namespace Elastic.Documentation.Links.InboundLinks; /// fetches cross-links for all the repositories defined in the publicized link-index.json file using the content source -public class LinksIndexCrossLinkFetcher(ILoggerFactory logFactory, ILinkIndexReader linkIndexProvider) : CrossLinkFetcher(logFactory, linkIndexProvider) +public class LinksIndexCrossLinkFetcher(ILoggerFactory logFactory, ILinkIndexReader linkIndexProvider) : CrossLinkFetcher( + logFactory, + linkIndexProvider +) { public override async Task FetchCrossLinks(Cancel ctx) { @@ -37,5 +40,4 @@ public override async Task FetchCrossLinks(Cancel ctx) LinkIndexEntries = linkEntries.ToFrozenDictionary(), }; } - } diff --git a/src/Elastic.Documentation.Links/InboundLinks/LinkIndexLinkChecker.cs b/src/Elastic.Documentation.Links/InboundLinks/LinkIndexLinkChecker.cs index fc1ae6e2aa..a56ddc802d 100644 --- a/src/Elastic.Documentation.Links/InboundLinks/LinkIndexLinkChecker.cs +++ b/src/Elastic.Documentation.Links/InboundLinks/LinkIndexLinkChecker.cs @@ -38,28 +38,39 @@ public async Task CheckRepository(IDiagnosticsCollector collector, string? var root = fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); if (fromRepository == null && toRepository == null) { - fromRepository ??= GitCheckoutInformationFactory.Create(root, fileSystem, logFactory.CreateLogger(nameof(GitCheckoutInformation))).RepositoryName; + fromRepository ??= + GitCheckoutInformationFactory.Create( + root, + fileSystem, + logFactory.CreateLogger(nameof(GitCheckoutInformation)) + ).RepositoryName; if (fromRepository == null) throw new Exception("Unable to determine repository name"); } var fetcher = new LinksIndexCrossLinkFetcher(logFactory, _linkIndexProvider); var crossLinks = await fetcher.FetchCrossLinks(ctx); var resolver = new CrossLinkResolver(crossLinks); - var filter = new RepositoryFilter - { - LinksTo = toRepository, - LinksFrom = fromRepository - }; + var filter = new RepositoryFilter { LinksTo = toRepository, LinksFrom = fromRepository }; return ValidateCrossLinks(collector, crossLinks, resolver, filter); } - public async Task CheckWithLocalLinksJson(IDiagnosticsCollector collector, string? file = null, string? path = null, Cancel ctx = default) + public async Task CheckWithLocalLinksJson( + IDiagnosticsCollector collector, + string? file = null, + string? path = null, + Cancel ctx = default + ) { file ??= ".artifacts/docs/html/links.json"; - var root = !string.IsNullOrEmpty(path) ? fileSystem.DirectoryInfo.New(path) : fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); - var repository = GitCheckoutInformationFactory.Create(root, fileSystem, logFactory.CreateLogger(nameof(GitCheckoutInformation))).RepositoryName - ?? throw new Exception("Unable to determine repository name"); + var root = !string.IsNullOrEmpty(path) + ? fileSystem.DirectoryInfo.New(path) + : fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); + var repository = GitCheckoutInformationFactory.Create( + root, + fileSystem, + logFactory.CreateLogger(nameof(GitCheckoutInformation)) + ).RepositoryName ?? throw new Exception("Unable to determine repository name"); var localLinksJson = fileSystem.FileInfo.New(Path.Join(root.FullName, file)); @@ -81,7 +92,6 @@ public async Task CheckWithLocalLinksJson(IDiagnosticsCollector collector, return false; } - _logger.LogInformation("Validating {File} in {Directory}", file, root.FullName); var fetcher = new LinksIndexCrossLinkFetcher(logFactory, _linkIndexProvider); var crossLinks = await fetcher.FetchCrossLinks(ctx); @@ -101,11 +111,11 @@ public async Task CheckWithLocalLinksJson(IDiagnosticsCollector collector, throw; } - _logger.LogInformation("Validating all cross links to {Repository}:// from all repositories published to link-index.json", repository); - var filter = new RepositoryFilter - { - LinksTo = repository - }; + _logger.LogInformation( + "Validating all cross links to {Repository}:// from all repositories published to link-index.json", + repository + ); + var filter = new RepositoryFilter { LinksTo = repository }; return ValidateCrossLinks(collector, crossLinks, resolver, filter); } @@ -140,19 +150,24 @@ RepositoryFilter filter var linksJson = $"https://elastic-docs-link-index.s3.us-east-2.amazonaws.com/elastic/{uri.Scheme}/main/links.json"; if (crossLinks.LinkIndexEntries.TryGetValue(uri.Scheme, out var linkIndexEntry)) linksJson = $"https://elastic-docs-link-index.s3.us-east-2.amazonaws.com/{linkIndexEntry.Path}"; - _ = resolver.TryResolve(s => - { - if (s.Contains("is not a valid link in the")) - { - // - var error = $"'elastic/{repository}' links to unknown file: " + s; - error = error.Replace("is not a valid link in the", "in the"); - collector.EmitError(linksJson, error); - return; - } - - collector.EmitError(repository, s); - }, uri, out _); + _ = + resolver.TryResolve( + s => + { + if (s.Contains("is not a valid link in the")) + { + // + var error = $"'elastic/{repository}' links to unknown file: " + s; + error = error.Replace("is not a valid link in the", "in the"); + collector.EmitError(linksJson, error); + return; + } + + collector.EmitError(repository, s); + }, + uri, + out _ + ); } } diff --git a/src/Elastic.Documentation.Navigation/Assembler/SectionNavigation.cs b/src/Elastic.Documentation.Navigation/Assembler/SectionNavigation.cs index 082f71d831..d37c87884c 100644 --- a/src/Elastic.Documentation.Navigation/Assembler/SectionNavigation.cs +++ b/src/Elastic.Documentation.Navigation/Assembler/SectionNavigation.cs @@ -24,8 +24,7 @@ public class SectionNavigation(string title) : IRootNavigationItem - public ILeafNavigationItem Index => - _index ??= new SectionIndexLeaf(new SectionIndexPage(Title), Url, this); + public ILeafNavigationItem Index => _index ??= new SectionIndexLeaf(new SectionIndexPage(Title), Url, this); /// public string NavigationTitle => Title; @@ -74,8 +73,7 @@ public record SectionIndexPage(string NavigationTitle) : IDocumentationFile /// Synthetic index leaf for a section landing page. [DebuggerDisplay("{Url}")] -public class SectionIndexLeaf(SectionIndexPage model, string url, SectionNavigation sectionRoot) - : ILeafNavigationItem +public class SectionIndexLeaf(SectionIndexPage model, string url, SectionNavigation sectionRoot) : ILeafNavigationItem { /// public IDocumentationFile Model { get; } = model; diff --git a/src/Elastic.Documentation.Navigation/Assembler/SectionTopNavBuilder.cs b/src/Elastic.Documentation.Navigation/Assembler/SectionTopNavBuilder.cs index 71d7a35900..9868e21880 100644 --- a/src/Elastic.Documentation.Navigation/Assembler/SectionTopNavBuilder.cs +++ b/src/Elastic.Documentation.Navigation/Assembler/SectionTopNavBuilder.cs @@ -29,14 +29,11 @@ public static class SectionTopNavBuilder // Index plain toc: items by Identifier for fast lookup. // Sections with children now live in the tree as SectionNavigation nodes and // are looked up by title instead. - var byIdentifier = topLevel - .OfType>() + var byIdentifier = topLevel.OfType>() .Where(item => item is not SectionNavigation) .ToDictionary(item => item.Identifier); - var sectionsByTitle = topLevel - .OfType() - .ToDictionary(s => s.Title, StringComparer.OrdinalIgnoreCase); + var sectionsByTitle = topLevel.OfType().ToDictionary(s => s.Title, StringComparer.OrdinalIgnoreCase); var items = new List(); @@ -58,8 +55,7 @@ public static class SectionTopNavBuilder if (tabUrl is not null) { - items.Add(new TopNavLinkItem(section.Title, tabUrl, IsExternal: false, - SectionId: sectionNav.Id)); + items.Add(new TopNavLinkItem(section.Title, tabUrl, IsExternal: false, SectionId: sectionNav.Id)); } } } @@ -68,11 +64,7 @@ public static class SectionTopNavBuilder // Plain toc: entry — one tab, active when NavigationRoot.Id == item.Id if (byIdentifier.TryGetValue(tocRef.Source, out var navItem)) { - items.Add(new TopNavLinkItem( - navItem.NavigationTitle, - navItem.Index.Url, - IsExternal: false, - SectionId: navItem.Id)); + items.Add(new TopNavLinkItem(navItem.NavigationTitle, navItem.Index.Url, IsExternal: false, SectionId: navItem.Id)); } } } diff --git a/src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs b/src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs index a8ad0d7eaf..428823bc88 100644 --- a/src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs +++ b/src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs @@ -104,9 +104,7 @@ public SiteNavigation( } // Resolve section URL from first child once children are built. - var firstChildUrl = sectionChildren - .OfType>() - .FirstOrDefault()?.Index.Url; + var firstChildUrl = sectionChildren.OfType>().FirstOrDefault()?.Index.Url; if (firstChildUrl is not null) sectionNav.Url = firstChildUrl; @@ -248,7 +246,8 @@ void IAssignableChildrenNavigation.SetNavigationItems(IReadOnlyCollection {NavigationRoot.Url}")] -public class NavigationHomeProvider(string pathPrefix, IRootNavigationItem navigationRoot) : INavigationHomeProvider +public class NavigationHomeProvider( + string pathPrefix, + IRootNavigationItem navigationRoot +) : INavigationHomeProvider { /// public string PathPrefix { get; } = pathPrefix; @@ -31,4 +34,3 @@ public class NavigationHomeProvider(string pathPrefix, IRootNavigationItem $"{PathPrefix} => {NavigationRoot.Url}"; } - diff --git a/src/Elastic.Documentation.Navigation/INavigationItem.cs b/src/Elastic.Documentation.Navigation/INavigationItem.cs index 5492ee3af7..e80e1cb7ef 100644 --- a/src/Elastic.Documentation.Navigation/INavigationItem.cs +++ b/src/Elastic.Documentation.Navigation/INavigationItem.cs @@ -53,20 +53,16 @@ public interface INavigationItem /// Represents a leaf node in the navigation tree with associated model data. /// The type attached to the navigation model. -public interface ILeafNavigationItem : INavigationItem - where TModel : INavigationModel +public interface ILeafNavigationItem : INavigationItem where TModel : INavigationModel { /// Gets the navigation model associated with this navigation item. TModel Model { get; } } - /// Represents a node in the navigation tree that can contain child items. /// The type of the index model. /// The type of child navigation items. -public interface INodeNavigationItem : INavigationItem - where TIndex : INavigationModel - where TChildNavigation : INavigationItem +public interface INodeNavigationItem : INavigationItem where TIndex : INavigationModel where TChildNavigation : INavigationItem { /// Gets the unique identifier for this node. string Id { get; } @@ -93,9 +89,7 @@ public interface IAssignableIslandNavigation bool IsIsland { get; set; } } -public interface IRootNavigationItem : INodeNavigationItem, IAssignableChildrenNavigation - where TIndex : INavigationModel - where TChildNavigation : INavigationItem +public interface IRootNavigationItem : INodeNavigationItem, IAssignableChildrenNavigation where TIndex : INavigationModel where TChildNavigation : INavigationItem { bool IsUsingNavigationDropdown { get; } diff --git a/src/Elastic.Documentation.Navigation/INavigationTraversable.cs b/src/Elastic.Documentation.Navigation/INavigationTraversable.cs index 3ebe8bf2dc..6885070c1b 100644 --- a/src/Elastic.Documentation.Navigation/INavigationTraversable.cs +++ b/src/Elastic.Documentation.Navigation/INavigationTraversable.cs @@ -23,7 +23,8 @@ public INavigationItem[] GetParents() parents.Add(parent); parent = parent.Parent; - } while (parent != null); + } + while (parent != null); return [.. parents]; } @@ -35,10 +36,7 @@ public string? NavigationSection get { var parents = navigationItem.GetParents(); - var meaningful = parents - .Reverse() - .Skip(1) - .FirstOrDefault(); + var meaningful = parents.Reverse().Skip(1).FirstOrDefault(); if (meaningful is not null) return meaningful.NavigationTitle.ToLowerInvariant(); return navigationItem.NavigationTitle.ToLowerInvariant(); @@ -63,8 +61,8 @@ IEnumerable YieldAll() current = GetNext(current); if (current is not null) yield return current; - - } while (current is not null); + } + while (current is not null); } INavigationItem? GetPrevious(IDocumentationFile current) @@ -82,7 +80,8 @@ IEnumerable YieldAll() if (previous is not null && !previous.Hidden && previous.Url != currentNavigation.Url) return previous; index--; - } while (index >= 0); + } + while (index >= 0); return null; } @@ -102,17 +101,20 @@ IEnumerable YieldAll() if (next is not null && !next.Hidden && next.Url != currentNavigation.Url) return next; index++; - } while (index <= NavigationIndexedByOrder.Count - 1); + } + while (index <= NavigationIndexedByOrder.Count - 1); return null; } INavigationItem GetNavigationFor(IDocumentationFile file) => NavigationDocumentationFileLookup.TryGetValue(file, out var navigation) - ? navigation : throw new InvalidOperationException( + ? navigation + : throw new InvalidOperationException( file.SourcePath is { } path ? $"'{file.NavigationTitle}' ({path}) is not listed in the table of contents. Add it to the toc.yml for this documentation set." - : $"'{file.NavigationTitle}' is not listed in the table of contents."); + : $"'{file.NavigationTitle}' is not listed in the table of contents." + ); INavigationItem[] GetParents(INavigationItem current) => current.GetParents(); diff --git a/src/Elastic.Documentation.Navigation/Isolated/DocumentationNavigationFactory.cs b/src/Elastic.Documentation.Navigation/Isolated/DocumentationNavigationFactory.cs index 2a6e104b35..5e8de4a265 100644 --- a/src/Elastic.Documentation.Navigation/Isolated/DocumentationNavigationFactory.cs +++ b/src/Elastic.Documentation.Navigation/Isolated/DocumentationNavigationFactory.cs @@ -31,14 +31,18 @@ public static class DocumentationNavigationFactory /// /// Creates a file navigation leaf from a documentation file model. /// - public static ILeafNavigationItem CreateFileNavigationLeaf(TModel model, IFileInfo fileInfo, FileNavigationArgs args) - where TModel : IDocumentationFile => - new FileNavigationLeaf(model, fileInfo, args) { NavigationIndex = args.NavigationIndex }; + public static ILeafNavigationItem CreateFileNavigationLeaf( + TModel model, + IFileInfo fileInfo, + FileNavigationArgs args + ) where TModel : IDocumentationFile => new FileNavigationLeaf(model, fileInfo, args) { NavigationIndex = args.NavigationIndex }; /// /// Creates a virtual file navigation node from a documentation file model. /// - public static VirtualFileNavigation CreateVirtualFileNavigation(TModel model, IFileInfo fileInfo, VirtualFileNavigationArgs args) - where TModel : IDocumentationFile => - new(model, fileInfo, args) { NavigationIndex = args.NavigationIndex }; + public static VirtualFileNavigation CreateVirtualFileNavigation( + TModel model, + IFileInfo fileInfo, + VirtualFileNavigationArgs args + ) where TModel : IDocumentationFile => new(model, fileInfo, args) { NavigationIndex = args.NavigationIndex }; } diff --git a/src/Elastic.Documentation.Navigation/Isolated/Leaf/CrossLinkNavigationLeaf.cs b/src/Elastic.Documentation.Navigation/Isolated/Leaf/CrossLinkNavigationLeaf.cs index a09eab2dcd..8b54acd545 100644 --- a/src/Elastic.Documentation.Navigation/Isolated/Leaf/CrossLinkNavigationLeaf.cs +++ b/src/Elastic.Documentation.Navigation/Isolated/Leaf/CrossLinkNavigationLeaf.cs @@ -27,8 +27,7 @@ public class CrossLinkNavigationLeaf( bool hidden, INodeNavigationItem? parent, INavigationHomeAccessor homeAccessor -) - : ILeafNavigationItem +) : ILeafNavigationItem { /// public CrossLinkModel Model { get; } = model; @@ -50,5 +49,4 @@ INavigationHomeAccessor homeAccessor /// public int NavigationIndex { get; set; } - } diff --git a/src/Elastic.Documentation.Navigation/Isolated/Leaf/FileNavigationLeaf.cs b/src/Elastic.Documentation.Navigation/Isolated/Leaf/FileNavigationLeaf.cs index 030ef8e160..8dcb3e4cf9 100644 --- a/src/Elastic.Documentation.Navigation/Isolated/Leaf/FileNavigationLeaf.cs +++ b/src/Elastic.Documentation.Navigation/Isolated/Leaf/FileNavigationLeaf.cs @@ -10,8 +10,11 @@ namespace Elastic.Documentation.Navigation.Isolated.Leaf; [DebuggerDisplay("{Url}")] -public class FileNavigationLeaf(TModel model, IFileInfo fileInfo, FileNavigationArgs args) : ILeafNavigationItem - where TModel : IDocumentationFile +public class FileNavigationLeaf( + TModel model, + IFileInfo fileInfo, + FileNavigationArgs args +) : ILeafNavigationItem where TModel : IDocumentationFile { public IFileInfo FileInfo { get; } = fileInfo; @@ -29,7 +32,6 @@ public string Url if (_homeProviderCache is not null && _homeProviderCache == args.HomeAccessor.HomeProvider.Id && _urlCache is not null) return _urlCache; - _homeProviderCache = args.HomeAccessor.HomeProvider.Id; _urlCache = DetermineUrl(); @@ -45,12 +47,14 @@ string DetermineUrl() relativePath = relativePath.OptionalWindowsReplace(); relativePath = Path.ChangeExtension(relativePath, "md"); var path = relativePath.EndsWith(".md", StringComparison.OrdinalIgnoreCase) - ? relativePath[..^3] // Remove last 3 characters (.md) + ? relativePath[..^3] // Remove last 3 characters (.md) + : relativePath; // If a path ends with /index or is just index, omit it from the URL if (path.EndsWith("/index", StringComparison.OrdinalIgnoreCase)) path = path[..^6]; // Remove "/index" + else if (path.Equals("index", StringComparison.OrdinalIgnoreCase)) return string.IsNullOrEmpty(rootUrl) ? "/" : $"{rootUrl}"; @@ -79,5 +83,4 @@ string DetermineUrl() /// public int NavigationIndex { get; set; } - } diff --git a/src/Elastic.Documentation.Navigation/Isolated/Node/DocumentationSetNavigation.cs b/src/Elastic.Documentation.Navigation/Isolated/Node/DocumentationSetNavigation.cs index d91b904239..d3fb0a9708 100644 --- a/src/Elastic.Documentation.Navigation/Isolated/Node/DocumentationSetNavigation.cs +++ b/src/Elastic.Documentation.Navigation/Isolated/Node/DocumentationSetNavigation.cs @@ -27,10 +27,7 @@ public interface IDocumentationSetNavigation } [DebuggerDisplay("{Url}")] -public class DocumentationSetNavigation - : IDocumentationSetNavigation, IRootNavigationItem, INavigationHomeAccessor, INavigationHomeProvider, IAssignableIslandNavigation - - where TModel : class, IDocumentationFile +public class DocumentationSetNavigation : IDocumentationSetNavigation, IRootNavigationItem, INavigationHomeAccessor, INavigationHomeProvider, IAssignableIslandNavigation where TModel : class, IDocumentationFile { private readonly IDocumentationFileFactory _factory; private readonly ICrossLinkResolver _crossLinkResolver; @@ -66,13 +63,7 @@ public DocumentationSetNavigation( var index = -1; foreach (var tocItem in documentationSet.TableOfContents) { - var navItem = ConvertToNavigationItem( - tocItem, - index++, - context, - parent: this, - homeAccessor: this - ); + var navItem = ConvertToNavigationItem(tocItem, index++, context, parent: this, homeAccessor: this); if (navItem != null) items.Add(navItem); @@ -86,7 +77,10 @@ public DocumentationSetNavigation( // Emit error if TOC was defined but no items could be created if (documentationSet.TableOfContents.Count > 0) - context.EmitError(context.ConfigurationPath, $"Documentation set '{setName}' ({setPath}) table of contents has items defined but none could be created"); + context.EmitError( + context.ConfigurationPath, + $"Documentation set '{setName}' ({setPath}) table of contents has items defined but none could be created" + ); // Emit error if TOC was never defined else context.EmitError(context.ConfigurationPath, $"Documentation set '{setName}' ({setPath}) has no table of contents defined"); @@ -101,7 +95,6 @@ public DocumentationSetNavigation( NavigationItems = navigationItems; _ = this.UpdateNavigationIndex(context); } - } /// @@ -159,7 +152,8 @@ public DocumentationSetNavigation( /// public IReadOnlyCollection NavigationItems { get; private set; } - void IAssignableChildrenNavigation.SetNavigationItems(IReadOnlyCollection navigationItems) => SetNavigationItems(navigationItems); + void IAssignableChildrenNavigation.SetNavigationItems(IReadOnlyCollection navigationItems) => + SetNavigationItems(navigationItems); private void SetNavigationItems(IReadOnlyCollection navigationItems) { var indexNavigation = navigationItems.QueryIndex(this, $"{PathPrefix}/index.md", out navigationItems); @@ -168,24 +162,22 @@ private void SetNavigationItems(IReadOnlyCollection navigationI _ = this.UpdateNavigationIndex(_context); } - private INavigationItem? ConvertToNavigationItem( ITableOfContentsItem tocItem, int index, IDocumentationSetContext context, INodeNavigationItem? parent, INavigationHomeAccessor homeAccessor - ) => - tocItem switch - { - FileRef fileRef => CreateFileNavigation(fileRef, index, context, parent, homeAccessor), - CrossLinkRef crossLinkRef => CreateCrossLinkNavigation(crossLinkRef, index, parent, homeAccessor), - FolderRef folderRef => CreateFolderNavigation(folderRef, index, context, parent, homeAccessor), - IsolatedTableOfContentsRef tocRef => CreateTocNavigation(tocRef, index, context, parent, homeAccessor), - CliReferenceRef cliRef => CreateCliReferenceNavigation(cliRef, index, context, parent, homeAccessor), - ListingRef listingRef => CreateListingNavigation(listingRef, index, context, parent, homeAccessor), - _ => null - }; + ) => tocItem switch + { + FileRef fileRef => CreateFileNavigation(fileRef, index, context, parent, homeAccessor), + CrossLinkRef crossLinkRef => CreateCrossLinkNavigation(crossLinkRef, index, parent, homeAccessor), + FolderRef folderRef => CreateFolderNavigation(folderRef, index, context, parent, homeAccessor), + IsolatedTableOfContentsRef tocRef => CreateTocNavigation(tocRef, index, context, parent, homeAccessor), + CliReferenceRef cliRef => CreateCliReferenceNavigation(cliRef, index, context, parent, homeAccessor), + ListingRef listingRef => CreateListingNavigation(listingRef, index, context, parent, homeAccessor), + _ => null + }; /// /// Resolves the file info based on the file path. Since LoadAndResolve has already processed paths, @@ -201,19 +193,13 @@ private static IFileInfo ResolveFileInfo(IDocumentationSetContext context, strin /// /// Creates the documentation file from the factory, emitting an error if creation fails. /// - private TModel? CreateDocumentationFile( - IFileInfo fileInfo, - IFileSystem fileSystem, - IDocumentationSetContext context - ) + private TModel? CreateDocumentationFile(IFileInfo fileInfo, IFileSystem fileSystem, IDocumentationSetContext context) { var relativePath = Path.GetRelativePath(context.DocumentationSourceDirectory.FullName, fileInfo.FullName); var documentationFile = _factory.TryCreateDocumentationFile(fileInfo, fileSystem); if (documentationFile == null) { - var reason = fileInfo.Exists - ? "the file exists but is not a valid Markdown document" - : "the file does not exist on disk"; + var reason = fileInfo.Exists ? "the file exists but is not a valid Markdown document" : "the file does not exist on disk"; context.EmitError(context.ConfigurationPath, $"Table of contents references '{relativePath}' but {reason}."); } @@ -229,11 +215,13 @@ private static void EnsureIndexIsFirst(List children) return; // Find an item named "index" or "index.md" - var indexItem = children.FirstOrDefault(c => - c is ILeafNavigationItem leaf && - (leaf.Model.NavigationTitle.Equals("index", StringComparison.OrdinalIgnoreCase) || - (leaf is FileNavigationLeaf fileLeaf && - fileLeaf.FileInfo.Name.Equals("index.md", StringComparison.OrdinalIgnoreCase)))); + var indexItem = children.FirstOrDefault( + c => + c is ILeafNavigationItem leaf && + (leaf.Model.NavigationTitle.Equals("index", StringComparison.OrdinalIgnoreCase) || + (leaf is FileNavigationLeaf fileLeaf && + fileLeaf.FileInfo.Name.Equals("index.md", StringComparison.OrdinalIgnoreCase))) + ); // If found and it's not already first, move it to the front if (indexItem != null && children[0] != indexItem) @@ -267,7 +255,14 @@ INavigationHomeAccessor homeAccessor // Handle leaf case (no children) if (fileRef.Children.Count <= 0) { - var leafNavigationArgs = new FileNavigationArgs(fullPath, fileRef.PathRelativeToContainer, fileRef.Hidden, index, parent, homeAccessor); + var leafNavigationArgs = new FileNavigationArgs( + fullPath, + fileRef.PathRelativeToContainer, + fileRef.Hidden, + index, + parent, + homeAccessor + ); return DocumentationNavigationFactory.CreateFileNavigationLeaf(documentationFile, fileInfo, leafNavigationArgs); } @@ -280,7 +275,11 @@ INavigationHomeAccessor homeAccessor parent, homeAccessor ); - var fileNavigation = DocumentationNavigationFactory.CreateVirtualFileNavigation(documentationFile, fileInfo, virtualFileNavigationArgs); + var fileNavigation = DocumentationNavigationFactory.CreateVirtualFileNavigation( + documentationFile, + fileInfo, + virtualFileNavigationArgs + ); // Process children recursively var children = new List(); @@ -289,9 +288,12 @@ INavigationHomeAccessor homeAccessor foreach (var child in fileRef.Children) { var childNav = ConvertToNavigationItem( - child, childIndex++, context, + child, + childIndex++, + context, fileNavigation, homeAccessor // Depth will be set by child + ); if (childNav != null) children.Add(childNav); @@ -300,8 +302,7 @@ INavigationHomeAccessor homeAccessor // Validate and order children if (children.Count < 1) { - context.EmitError(context.ConfigurationPath, - $"File navigation '{fullPath}' has children defined but none could be created"); + context.EmitError(context.ConfigurationPath, $"File navigation '{fullPath}' has children defined but none could be created"); return null; } @@ -319,17 +320,17 @@ INavigationHomeAccessor homeAccessor ) { var title = crossLinkRef.Title ?? crossLinkRef.CrossLinkUri.OriginalString; - if (!_crossLinkResolver.TryResolve(s => _context.EmitError(_context.ConfigurationPath, s), crossLinkRef.CrossLinkUri, out var resolvedUri)) + if ( + !_crossLinkResolver.TryResolve( + s => _context.EmitError(_context.ConfigurationPath, s), + crossLinkRef.CrossLinkUri, + out var resolvedUri + ) + ) return null; var model = new CrossLinkModel(resolvedUri, title); - return new CrossLinkNavigationLeaf( - model, - resolvedUri.ToString(), - crossLinkRef.Hidden, - parent, - homeAccessor - ) + return new CrossLinkNavigationLeaf(model, resolvedUri.ToString(), crossLinkRef.Hidden, parent, homeAccessor) { NavigationIndex = index }; @@ -347,10 +348,7 @@ INavigationHomeAccessor homeAccessor var folderPath = folderRef.PathRelativeToDocumentationSet; // Create folder navigation with null parent initially - we'll pass it to children but set it properly after - var folderNavigation = new FolderNavigation(folderPath, parent, homeAccessor) - { - NavigationIndex = index - }; + var folderNavigation = new FolderNavigation(folderPath, parent, homeAccessor) { NavigationIndex = index }; // Process children - they can reference folderNavigation as their parent var children = new List(); @@ -359,13 +357,7 @@ INavigationHomeAccessor homeAccessor // LoadAndResolve has already populated children (either from YAML or auto-discovered) foreach (var child in folderRef.Children) { - var childNav = ConvertToNavigationItem( - child, - childIndex++, - context, - folderNavigation, - homeAccessor - ); + var childNav = ConvertToNavigationItem(child, childIndex++, context, folderNavigation, homeAccessor); if (childNav != null) children.Add(childNav); @@ -374,7 +366,10 @@ INavigationHomeAccessor homeAccessor // Validate that we have children (LoadAndResolve should have ensured this) if (children.Count == 0) { - context.Collector.EmitError(folderRef.Context, $"Folder navigation '{folderPath}' has children defined but none could be created ({folderRef.Context}:)"); + context.Collector.EmitError( + folderRef.Context, + $"Folder navigation '{folderPath}' has children defined but none could be created ({folderRef.Context}:)" + ); return null; } folderNavigation.SetNavigationItems(children); @@ -392,9 +387,9 @@ INavigationHomeAccessor homeAccessor // tocRef.Path is now the FULL path (e.g., "guides/api" or "setup/advanced") after LoadAndResolve var fullTocPath = tocRef.PathRelativeToDocumentationSet; - var tocDirectory = context.ReadFileSystem.DirectoryInfo.New( - context.ReadFileSystem.Path.Join(context.DocumentationSourceDirectory.FullName, fullTocPath) - ); + var tocDirectory = context.ReadFileSystem + .DirectoryInfo + .New(context.ReadFileSystem.Path.Join(context.DocumentationSourceDirectory.FullName, fullTocPath)); var assemblerBuild = context.BuildType == BuildType.Assembler; // for assembler builds we ensure toc's create their own home provider sot that they can be re-homed easily @@ -409,14 +404,13 @@ INavigationHomeAccessor homeAccessor tocDirectory, fullTocPath, parent, // Temporary null parent + isolatedHomeProvider.PathPrefix, Git, _tableOfContentNodes, isolatedHomeProvider ) - { - NavigationIndex = index - }; + { NavigationIndex = index }; // Convert children - pass tocNavigation as parent and tocHomeProvider as HomeProvider (TOC creates new scope) var children = new List(); @@ -427,13 +421,7 @@ INavigationHomeAccessor homeAccessor foreach (var child in tocRef.Children) { - var childNav = ConvertToNavigationItem( - child, - childIndex++, - context, - tocNavigation, - childHomeAccessor - ); + var childNav = ConvertToNavigationItem(child, childIndex++, context, tocNavigation, childHomeAccessor); if (childNav != null) children.Add(childNav); @@ -442,10 +430,12 @@ INavigationHomeAccessor homeAccessor // Validate TOCs have children if (children.Count == 0) { - context.Collector.EmitError(tocRef.Context, + context.Collector.EmitError( + tocRef.Context, tocRef.Children.Count == 0 ? $"Table of contents navigation '{fullTocPath}' has no children defined ({tocRef.Context}:)" - : $"Table of contents navigation '{fullTocPath}' has children defined but none could be created ({tocRef.Context}:)"); + : $"Table of contents navigation '{fullTocPath}' has children defined but none could be created ({tocRef.Context}:)" + ); return null; } tocNavigation.SetNavigationItems(children); @@ -463,8 +453,9 @@ INavigationHomeAccessor homeAccessor INavigationHomeAccessor homeAccessor ) { - var schemaFileInfo = context.ReadFileSystem.FileInfo.New( - context.ReadFileSystem.Path.Join(context.DocumentationSourceDirectory.FullName, cliRef.SchemaPath)); + var schemaFileInfo = context.ReadFileSystem + .FileInfo + .New(context.ReadFileSystem.Path.Join(context.DocumentationSourceDirectory.FullName, cliRef.SchemaPath)); CliSchema schema; try @@ -501,20 +492,47 @@ INavigationHomeAccessor homeAccessor // Shortcut alias pages first, then commands and namespaces foreach (var shortcut in schema.Shortcuts ?? []) { - var aliasNav = MakeFileLeaf(docSourceDir, virtualRoot, [shortcut.From], isNamespace: true, childIndex++, folderNavigation, homeAccessor, context); + var aliasNav = MakeFileLeaf( + docSourceDir, + virtualRoot, + [shortcut.From], + isNamespace: true, + childIndex++, + folderNavigation, + homeAccessor, + context + ); if (aliasNav is not null) children.Add(aliasNav); } foreach (var cmd in schema.Commands) { - var cmdNav = MakeFileLeaf(docSourceDir, virtualRoot, [cmd.Name], isNamespace: false, childIndex++, folderNavigation, homeAccessor, context); + var cmdNav = MakeFileLeaf( + docSourceDir, + virtualRoot, + [cmd.Name], + isNamespace: false, + childIndex++, + folderNavigation, + homeAccessor, + context + ); if (cmdNav is not null) children.Add(cmdNav); } foreach (var ns in schema.Namespaces) { - var nsNav = BuildNamespaceNavigation(docSourceDir, virtualRoot, ns, [ns.Segment], childIndex++, folderNavigation, homeAccessor, context); + var nsNav = BuildNamespaceNavigation( + docSourceDir, + virtualRoot, + ns, + [ns.Segment], + childIndex++, + folderNavigation, + homeAccessor, + context + ); if (nsNav is not null) children.Add(nsNav); } @@ -548,7 +566,16 @@ IDocumentationSetContext context var childIndex = 0; // Namespace index file - var nsIndexNav = MakeFileLeaf(docSourceDir, virtualRoot, segments, isNamespace: true, childIndex++, nsFolderNav, homeAccessor, context); + var nsIndexNav = MakeFileLeaf( + docSourceDir, + virtualRoot, + segments, + isNamespace: true, + childIndex++, + nsFolderNav, + homeAccessor, + context + ); if (nsIndexNav is not null) children.Add(nsIndexNav); @@ -556,7 +583,16 @@ IDocumentationSetContext context foreach (var cmd in ns.Commands ?? []) { var cmdSegments = segments.Append(cmd.Name).ToArray(); - var cmdNav = MakeFileLeaf(docSourceDir, virtualRoot, cmdSegments, isNamespace: false, childIndex++, nsFolderNav, homeAccessor, context); + var cmdNav = MakeFileLeaf( + docSourceDir, + virtualRoot, + cmdSegments, + isNamespace: false, + childIndex++, + nsFolderNav, + homeAccessor, + context + ); if (cmdNav is not null) children.Add(cmdNav); } @@ -565,7 +601,16 @@ IDocumentationSetContext context foreach (var subNs in ns.Namespaces ?? []) { var subSegments = segments.Append(subNs.Segment).ToArray(); - var subNav = BuildNamespaceNavigation(docSourceDir, virtualRoot, subNs, subSegments, childIndex++, nsFolderNav, homeAccessor, context); + var subNav = BuildNamespaceNavigation( + docSourceDir, + virtualRoot, + subNs, + subSegments, + childIndex++, + nsFolderNav, + homeAccessor, + context + ); if (subNav is not null) children.Add(subNav); } @@ -597,8 +642,7 @@ IDocumentationSetContext context var docFile = _factory.TryCreateDocumentationFile(fileInfo, context.ReadFileSystem); if (docFile is null) { - context.EmitError(context.ConfigurationPath, - $"CLI reference: could not create documentation file for '{syntheticPath}'"); + context.EmitError(context.ConfigurationPath, $"CLI reference: could not create documentation file for '{syntheticPath}'"); return null; } @@ -619,7 +663,10 @@ INavigationHomeAccessor homeAccessor var isIsland = listingRef.Options.Island; if (isIsland && visual == ListingVisual.None) { - context.Collector.EmitError(listingRef.Context, $"Listing '{listingPath}' sets island: true with visual: none. Set visual: groups or visual: all so the listing is reachable from the main nav."); + context.Collector.EmitError( + listingRef.Context, + $"Listing '{listingPath}' sets island: true with visual: none. Set visual: groups or visual: all so the listing is reachable from the main nav." + ); return null; } @@ -639,7 +686,14 @@ INavigationHomeAccessor homeAccessor var docFile = CreateDocumentationFile(fileInfo, context.ReadFileSystem, context); if (docFile is null) break; - var args = new FileNavigationArgs(indexRef.PathRelativeToDocumentationSet, indexRef.PathRelativeToContainer, false, childIndex++, folderNavigation, homeAccessor); + var args = new FileNavigationArgs( + indexRef.PathRelativeToDocumentationSet, + indexRef.PathRelativeToContainer, + false, + childIndex++, + folderNavigation, + homeAccessor + ); children.Add(DocumentationNavigationFactory.CreateFileNavigationLeaf(docFile, fileInfo, args)); break; } @@ -653,14 +707,25 @@ INavigationHomeAccessor homeAccessor var groupHidden = visual is ListingVisual.None; // Derive the group folder path from the first child's parent dir so URL generation works var groupFolderPath = groupRef.PathRelativeToDocumentationSet + "/" + groupRef.GroupKey; - var groupFolderNav = new FolderNavigation(groupFolderPath, folderNavigation, homeAccessor) { NavigationIndex = childIndex++ }; + var groupFolderNav = new FolderNavigation(groupFolderPath, folderNavigation, homeAccessor) + { + NavigationIndex = childIndex++ + }; var groupChildren = new List(); var groupChildIndex = 0; foreach (var groupItem in groupRef.Children) { - var pathDs = groupItem switch { FileRef fr => fr.PathRelativeToDocumentationSet, _ => groupItem.PathRelativeToDocumentationSet }; - var pathCont = groupItem switch { FileRef fr => fr.PathRelativeToContainer, _ => groupItem.PathRelativeToContainer }; + var pathDs = groupItem switch + { + FileRef fr => fr.PathRelativeToDocumentationSet, + _ => groupItem.PathRelativeToDocumentationSet + }; + var pathCont = groupItem switch + { + FileRef fr => fr.PathRelativeToContainer, + _ => groupItem.PathRelativeToContainer + }; // Group index page: hidden matches group-level visibility // Content pages: always hidden from nav, but NOT excluded from indexing (so search still works) @@ -682,9 +747,18 @@ INavigationHomeAccessor homeAccessor if (childDocFile is null) continue; - var leafArgs = new FileNavigationArgs(pathDs, pathCont, pageHidden, groupChildIndex++, groupFolderNav, homeAccessor, - ExcludeFromIndexing: excludeFromIndexing); - groupChildren.Add(DocumentationNavigationFactory.CreateFileNavigationLeaf(childDocFile, childFileInfo, leafArgs)); + var leafArgs = new FileNavigationArgs( + pathDs, + pathCont, + pageHidden, + groupChildIndex++, + groupFolderNav, + homeAccessor, + ExcludeFromIndexing: excludeFromIndexing + ); + groupChildren.Add( + DocumentationNavigationFactory.CreateFileNavigationLeaf(childDocFile, childFileInfo, leafArgs) + ); } if (groupChildren.Count == 0) @@ -700,8 +774,15 @@ INavigationHomeAccessor homeAccessor var docFile = CreateDocumentationFile(fileInfo, context.ReadFileSystem, context); if (docFile is null) break; - var args = new FileNavigationArgs(fileRef.PathRelativeToDocumentationSet, fileRef.PathRelativeToContainer, true, childIndex++, folderNavigation, homeAccessor, - ExcludeFromIndexing: false); + var args = new FileNavigationArgs( + fileRef.PathRelativeToDocumentationSet, + fileRef.PathRelativeToContainer, + true, + childIndex++, + folderNavigation, + homeAccessor, + ExcludeFromIndexing: false + ); children.Add(DocumentationNavigationFactory.CreateFileNavigationLeaf(docFile, fileInfo, args)); break; } @@ -740,12 +821,9 @@ private static string SyntheticRelativePath(string virtualRoot, string[] segment else { // Keep cmd- prefix only for "index" commands to avoid collision with namespace index.md pages - var cmdName = segments[^1].Equals("index", StringComparison.OrdinalIgnoreCase) - ? $"cmd-{segments[^1]}" - : segments[^1]; + var cmdName = segments[^1].Equals("index", StringComparison.OrdinalIgnoreCase) ? $"cmd-{segments[^1]}" : segments[^1]; var parentPath = segments.Length > 1 ? string.Join("/", segments[..^1]) + "/" : string.Empty; return $"{virtualRoot}/{parentPath}{cmdName}.md"; } } - } diff --git a/src/Elastic.Documentation.Navigation/Isolated/Node/FolderNavigation.cs b/src/Elastic.Documentation.Navigation/Isolated/Node/FolderNavigation.cs index 05ce21068d..a1858aa94a 100644 --- a/src/Elastic.Documentation.Navigation/Isolated/Node/FolderNavigation.cs +++ b/src/Elastic.Documentation.Navigation/Isolated/Node/FolderNavigation.cs @@ -11,9 +11,8 @@ namespace Elastic.Documentation.Navigation.Isolated.Node; public class FolderNavigation( string parentPath, INodeNavigationItem? parent, - INavigationHomeAccessor homeAccessor) - : INodeNavigationItem, IAssignableChildrenNavigation, IAssignableIslandNavigation - where TModel : class, IDocumentationFile + INavigationHomeAccessor homeAccessor +) : INodeNavigationItem, IAssignableChildrenNavigation, IAssignableIslandNavigation where TModel : class, IDocumentationFile { // Will be set by SetNavigationItems @@ -48,7 +47,8 @@ public class FolderNavigation( public IReadOnlyCollection NavigationItems { get; private set; } = []; - void IAssignableChildrenNavigation.SetNavigationItems(IReadOnlyCollection navigationItems) => SetNavigationItems(navigationItems); + void IAssignableChildrenNavigation.SetNavigationItems(IReadOnlyCollection navigationItems) => + SetNavigationItems(navigationItems); internal void SetNavigationItems(IReadOnlyCollection navigationItems) { var indexNavigation = navigationItems.QueryIndex(this, $"{FolderPath}/index.md", out navigationItems); diff --git a/src/Elastic.Documentation.Navigation/Isolated/Node/TableOfContentsNavigation.cs b/src/Elastic.Documentation.Navigation/Isolated/Node/TableOfContentsNavigation.cs index 51dbf5ca8d..c65d8af99c 100644 --- a/src/Elastic.Documentation.Navigation/Isolated/Node/TableOfContentsNavigation.cs +++ b/src/Elastic.Documentation.Navigation/Isolated/Node/TableOfContentsNavigation.cs @@ -9,11 +9,7 @@ namespace Elastic.Documentation.Navigation.Isolated.Node; [DebuggerDisplay("{Url}")] -public class TableOfContentsNavigation : IRootNavigationItem - , INavigationHomeAccessor - , INavigationHomeProvider - , IAssignableIslandNavigation - where TModel : class, IDocumentationFile +public class TableOfContentsNavigation : IRootNavigationItem, INavigationHomeAccessor, INavigationHomeProvider, IAssignableIslandNavigation where TModel : class, IDocumentationFile { public TableOfContentsNavigation( IDirectoryInfo tableOfContentsDirectory, @@ -103,7 +99,8 @@ INavigationHomeProvider homeProvider public IReadOnlyCollection NavigationItems { get; private set; } - void IAssignableChildrenNavigation.SetNavigationItems(IReadOnlyCollection navigationItems) => SetNavigationItems(navigationItems); + void IAssignableChildrenNavigation.SetNavigationItems(IReadOnlyCollection navigationItems) => + SetNavigationItems(navigationItems); internal void SetNavigationItems(IReadOnlyCollection navigationItems) { var indexNavigation = navigationItems.QueryIndex(this, $"{ParentPath}/index.md", out navigationItems); diff --git a/src/Elastic.Documentation.Navigation/Isolated/Node/VirtualFileNavigation.cs b/src/Elastic.Documentation.Navigation/Isolated/Node/VirtualFileNavigation.cs index 43c75dc0ac..91cd609744 100644 --- a/src/Elastic.Documentation.Navigation/Isolated/Node/VirtualFileNavigation.cs +++ b/src/Elastic.Documentation.Navigation/Isolated/Node/VirtualFileNavigation.cs @@ -11,9 +11,11 @@ namespace Elastic.Documentation.Navigation.Isolated.Node; /// Represents a file navigation item that defines children which are not part of the file tree. [DebuggerDisplay("{Url}")] -public class VirtualFileNavigation(TModel model, IFileInfo fileInfo, VirtualFileNavigationArgs args) - : INodeNavigationItem, IAssignableChildrenNavigation - where TModel : IDocumentationFile +public class VirtualFileNavigation( + TModel model, + IFileInfo fileInfo, + VirtualFileNavigationArgs args +) : INodeNavigationItem, IAssignableChildrenNavigation where TModel : IDocumentationFile { /// public string Url => Index.Url; @@ -38,11 +40,22 @@ public class VirtualFileNavigation(TModel model, IFileInfo fileInfo, Vir public string Id => ShortId.Create(NavigationRoot.Id, Index.Url); /// - public ILeafNavigationItem Index { get; } = - new FileNavigationLeaf(model, fileInfo, new FileNavigationArgs(args.RelativePathToDocumentationSet, args.RelativePathToTableOfContents, args.Hidden, args.NavigationIndex, args.Parent, args.HomeAccessor)); + public ILeafNavigationItem Index { get; } = new FileNavigationLeaf( + model, + fileInfo, + new FileNavigationArgs( + args.RelativePathToDocumentationSet, + args.RelativePathToTableOfContents, + args.Hidden, + args.NavigationIndex, + args.Parent, + args.HomeAccessor + ) + ); public IReadOnlyCollection NavigationItems { get; private set; } = []; - void IAssignableChildrenNavigation.SetNavigationItems(IReadOnlyCollection navigationItems) => SetNavigationItems(navigationItems); + void IAssignableChildrenNavigation.SetNavigationItems(IReadOnlyCollection navigationItems) => + SetNavigationItems(navigationItems); internal void SetNavigationItems(IReadOnlyCollection navigationItems) => NavigationItems = navigationItems; } diff --git a/src/Elastic.Documentation.Navigation/NavigationItemExtensions.cs b/src/Elastic.Documentation.Navigation/NavigationItemExtensions.cs index 769d81a5df..44635a6488 100644 --- a/src/Elastic.Documentation.Navigation/NavigationItemExtensions.cs +++ b/src/Elastic.Documentation.Navigation/NavigationItemExtensions.cs @@ -16,8 +16,7 @@ public static class NavigationItemExtensions /// has a parent — the parent check suppresses island behaviour on an isolated docset root, which has no parent /// but would otherwise render as an island in a single-repo serve. /// - public static bool RendersAsIsland(this INavigationItem item) => - item.IsIsland && item.Parent is not null; + public static bool RendersAsIsland(this INavigationItem item) => item.IsIsland && item.Parent is not null; /// /// Walks and then its ancestors, returning the first node that @@ -33,15 +32,16 @@ public static bool RendersAsIsland(this INavigationItem item) => return null; } public static ILeafNavigationItem QueryIndex( - this IReadOnlyCollection items, INodeNavigationItem node, string fallbackPath, out IReadOnlyCollection children - ) - where TModel : class, IDocumentationFile + this IReadOnlyCollection items, + INodeNavigationItem node, + string fallbackPath, + out IReadOnlyCollection children + ) where TModel : class, IDocumentationFile { // Path match first — works even when the index leaf is Hidden (e.g. island listing groups // where the index is hidden to suppress it from the main nav tree but must still be the // folder's canonical index so QueryIndex returns it correctly). - var index = items.OfType>() - .FirstOrDefault(l => l.Model.SourcePath == fallbackPath); + var index = items.OfType>().FirstOrDefault(l => l.Model.SourcePath == fallbackPath); index ??= LookupIndex(preferVisible: true); index ??= LookupIndex(preferVisible: false); @@ -75,16 +75,21 @@ this IReadOnlyCollection items, INodeNavigationItem(this IRootNavigationItem node, IDocumentationContext context) - where TModel : IDocumentationFile + public static int UpdateNavigationIndex( + this IRootNavigationItem node, + IDocumentationContext context + ) where TModel : IDocumentationFile { var navigationIndex = -1; ProcessNavigationItem(context, ref navigationIndex, node); return navigationIndex; - } - private static void UpdateNavigationIndex(IReadOnlyCollection navigationItems, IDocumentationContext context, ref int navigationIndex) + private static void UpdateNavigationIndex( + IReadOnlyCollection navigationItems, + IDocumentationContext context, + ref int navigationIndex + ) { foreach (var item in navigationItems) ProcessNavigationItem(context, ref navigationIndex, item); @@ -105,7 +110,10 @@ private static void ProcessNavigationItem(IDocumentationContext context, ref int UpdateNavigationIndex(node.NavigationItems, context, ref navigationIndex); break; default: - context.EmitError(context.ConfigurationPath, $"{nameof(UpdateNavigationIndex)}: Unhandled navigation item type: {item.GetType()}"); + context.EmitError( + context.ConfigurationPath, + $"{nameof(UpdateNavigationIndex)}: Unhandled navigation item type: {item.GetType()}" + ); break; } } @@ -118,7 +126,8 @@ private static void ProcessNavigationItem(IDocumentationContext context, ref int /// The ConditionalWeakTable to populate with file-to-navigation mappings /// A frozen dictionary mapping navigation indices to navigation items public static FrozenDictionary BuildNavigationLookups( - this INavigationItem rootItem, ConditionalWeakTable navigationDocumentationFileLookup + this INavigationItem rootItem, + ConditionalWeakTable navigationDocumentationFileLookup ) { var navigationByOrder = new Dictionary(); @@ -132,7 +141,8 @@ public static FrozenDictionary BuildNavigationLookups( private static void BuildNavigationLookupsRecursive( INavigationItem item, ConditionalWeakTable navigationDocumentationFileLookup, - Dictionary navigationByOrder) + Dictionary navigationByOrder + ) { switch (item) { diff --git a/src/Elastic.Documentation.OpenApiIndex/CloudFrontCacheInvalidator.cs b/src/Elastic.Documentation.OpenApiIndex/CloudFrontCacheInvalidator.cs index bfd658c10f..8734fd1d2d 100644 --- a/src/Elastic.Documentation.OpenApiIndex/CloudFrontCacheInvalidator.cs +++ b/src/Elastic.Documentation.OpenApiIndex/CloudFrontCacheInvalidator.cs @@ -26,11 +26,7 @@ public async Task InvalidateAsync(IReadOnlyList paths, string callerRefe InvalidationBatch = new InvalidationBatch { CallerReference = callerReference, - Paths = new Paths - { - Quantity = paths.Count, - Items = [.. paths] - } + Paths = new Paths { Quantity = paths.Count, Items = [.. paths] } } }; diff --git a/src/Elastic.Documentation.OpenApiIndex/VersionIndexBuilder.cs b/src/Elastic.Documentation.OpenApiIndex/VersionIndexBuilder.cs index da338c043c..292ded37e8 100644 --- a/src/Elastic.Documentation.OpenApiIndex/VersionIndexBuilder.cs +++ b/src/Elastic.Documentation.OpenApiIndex/VersionIndexBuilder.cs @@ -25,8 +25,7 @@ public static (RootVersionIndex Index, IReadOnlyList InvalidKeys) Build( foreach (var key in keys) { - if (!TryParseKey(key, out var repo, out var version, out var file) || - !TryParseVersion(version, out var major, out var minor)) + if (!TryParseKey(key, out var repo, out var version, out var file) || !TryParseVersion(version, out var major, out var minor)) { invalidKeys.Add(key); continue; @@ -85,8 +84,10 @@ private static bool TryParseVersion(string version, out string major, out int mi // NumberStyles.None, so a segment carrying a sign or surrounding whitespace cannot reach the index // under a key that no longer matches the text it was parsed from. - if (!int.TryParse(version[..dot], NumberStyles.None, CultureInfo.InvariantCulture, out _) || - !int.TryParse(version[(dot + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out minor)) + if ( + !int.TryParse(version[..dot], NumberStyles.None, CultureInfo.InvariantCulture, out _) || + !int.TryParse(version[(dot + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out minor) + ) return false; major = version[..dot]; diff --git a/src/Elastic.Documentation.OpenApiIndex/VersionIndexPublisher.cs b/src/Elastic.Documentation.OpenApiIndex/VersionIndexPublisher.cs index a43a2f3cef..b950c3fba4 100644 --- a/src/Elastic.Documentation.OpenApiIndex/VersionIndexPublisher.cs +++ b/src/Elastic.Documentation.OpenApiIndex/VersionIndexPublisher.cs @@ -31,7 +31,10 @@ public async Task> RefreshAsync(Cancel ctx) { var keys = await ListSpecKeysAsync(ctx).ConfigureAwait(false); var (index, invalidKeys) = VersionIndexBuilder.Build(keys); - var json = JsonSerializer.Serialize(index, VersionIndexJsonContext.Default.SortedDictionaryStringSortedDictionaryStringSortedDictionaryStringVersionIndexEntry); + var json = JsonSerializer.Serialize( + index, + VersionIndexJsonContext.Default.SortedDictionaryStringSortedDictionaryStringSortedDictionaryStringVersionIndexEntry + ); var existing = await TryGetExistingIndexAsync(ctx).ConfigureAwait(false); if (!string.Equals(existing?.Body, json, StringComparison.Ordinal)) @@ -55,7 +58,8 @@ private async Task> ListSpecKeysAsync(Cancel ctx) keys.Add(s3Object.Key); } request.ContinuationToken = response.NextContinuationToken; - } while (response.IsTruncated == true); + } + while (response.IsTruncated == true); return keys; } @@ -65,11 +69,8 @@ private async Task> ListSpecKeysAsync(Cancel ctx) { try { - using var response = await s3Client.GetObjectAsync(new GetObjectRequest - { - BucketName = bucketName, - Key = IndexKey - }, ctx).ConfigureAwait(false); + using var response = + await s3Client.GetObjectAsync(new GetObjectRequest { BucketName = bucketName, Key = IndexKey }, ctx).ConfigureAwait(false); using var reader = new StreamReader(response.ResponseStream); return (response.ETag, await reader.ReadToEndAsync(ctx).ConfigureAwait(false)); diff --git a/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs b/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs index 42199a895d..194e1efcf7 100644 --- a/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs +++ b/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs @@ -22,17 +22,19 @@ namespace Elastic.Documentation.ServiceDefaults; public static class AppDefaultsExtensions { - public static TBuilder AddDocumentationServiceDefaults(this TBuilder builder) - where TBuilder : IHostApplicationBuilder => builder.AddDocumentationServiceDefaults(new GlobalCliOptions(), null); + public static TBuilder AddDocumentationServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder => + builder.AddDocumentationServiceDefaults(new GlobalCliOptions(), null); - public static TBuilder AddDocumentationServiceDefaults(this TBuilder builder, Action? configure) - where TBuilder : IHostApplicationBuilder => builder.AddDocumentationServiceDefaults(new GlobalCliOptions(), configure); + public static TBuilder AddDocumentationServiceDefaults( + this TBuilder builder, + Action? configure + ) where TBuilder : IHostApplicationBuilder => builder.AddDocumentationServiceDefaults(new GlobalCliOptions(), configure); public static TBuilder AddDocumentationServiceDefaults( this TBuilder builder, GlobalCliOptions cliOptions, - Action? configure = null) - where TBuilder : IHostApplicationBuilder + Action? configure = null + ) where TBuilder : IHostApplicationBuilder { // Map ENVIRONMENT (dev/edge/staging/prod) to the .NET hosting environment so // IsDevelopment()/IsStaging()/IsProduction() reflect the real deployment environment. @@ -42,7 +44,8 @@ public static TBuilder AddDocumentationServiceDefaults( builder.Environment.EnvironmentName = dotnetEnv; // We do not use appsettings.json — all config comes from env vars / user secrets / code. - var jsonSources = builder.Configuration.Sources + var jsonSources = builder.Configuration + .Sources .OfType() .Where(s => s.Path is not null && s.Path.StartsWith("appsettings", StringComparison.OrdinalIgnoreCase)) .ToList(); @@ -53,22 +56,27 @@ public static TBuilder AddDocumentationServiceDefaults( .AddElasticDocumentationLogging(cliOptions.LogLevel) .ConfigureHttpClientDefaults(http => { - _ = http.AddStandardResilienceHandler(options => - { - options.Retry.DisableForUnsafeHttpMethods(); - }); - }) - .AddConfigurationFileProvider(cliOptions.SkipPrivateRepositories, cliOptions.ConfigSource, (s, p) => - { - var versionConfiguration = p.CreateVersionConfiguration(); - var products = p.CreateProducts(versionConfiguration); - var search = p.CreateSearchConfiguration(); - _ = s.AddSingleton(p.CreateLegacyUrlMappings(products)); - _ = s.AddSingleton(products); - _ = s.AddSingleton(versionConfiguration); - _ = s.AddSingleton(search); - configure?.Invoke(s, p); + _ = + http.AddStandardResilienceHandler(options => + { + options.Retry.DisableForUnsafeHttpMethods(); + }); }) + .AddConfigurationFileProvider( + cliOptions.SkipPrivateRepositories, + cliOptions.ConfigSource, + (s, p) => + { + var versionConfiguration = p.CreateVersionConfiguration(); + var products = p.CreateProducts(versionConfiguration); + var search = p.CreateSearchConfiguration(); + _ = s.AddSingleton(p.CreateLegacyUrlMappings(products)); + _ = s.AddSingleton(products); + _ = s.AddSingleton(versionConfiguration); + _ = s.AddSingleton(search); + configure?.Invoke(s, p); + } + ) .AddSingleton(cliOptions); var endpoints = ElasticsearchEndpointFactory.Create(builder.Configuration); @@ -77,22 +85,24 @@ public static TBuilder AddDocumentationServiceDefaults( return builder; } - public static TServiceCollection AddElasticDocumentationLogging(this TServiceCollection services, LogLevel logLevel) - where TServiceCollection : IServiceCollection + public static TServiceCollection AddElasticDocumentationLogging( + this TServiceCollection services, + LogLevel logLevel + ) where TServiceCollection : IServiceCollection { - _ = services.AddLogging(x => - { - _ = x.ClearProviders().SetMinimumLevel(logLevel); - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - _ = x.AddConsole(c => c.FormatterName = "condensed"); - }); + _ = + services.AddLogging(x => + { + _ = x.ClearProviders().SetMinimumLevel(logLevel); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + _ = x.AddConsole(c => c.FormatterName = "condensed"); + }); return services; } public static TBuilder HealthCheckBuilderExtensions(this TBuilder builder) where TBuilder : IHostApplicationBuilder { - _ = builder.Services.AddHealthChecks() - .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + _ = builder.Services.AddHealthChecks().AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); return builder; } diff --git a/src/Elastic.Documentation.ServiceDefaults/DeploymentEnvironment.cs b/src/Elastic.Documentation.ServiceDefaults/DeploymentEnvironment.cs index 98133eb678..a0ea2e4598 100644 --- a/src/Elastic.Documentation.ServiceDefaults/DeploymentEnvironment.cs +++ b/src/Elastic.Documentation.ServiceDefaults/DeploymentEnvironment.cs @@ -24,12 +24,11 @@ public static class DeploymentEnvironment /// Returns null when the value is absent or unrecognized — callers should skip /// setting in that case. /// - public static string? ToDotnetEnvironment(string? environment) => - environment?.Trim().ToLowerInvariant() switch - { - "prod" => Environments.Production, - "staging" or "edge" => Environments.Staging, - "dev" => Environments.Development, - _ => null - }; + public static string? ToDotnetEnvironment(string? environment) => environment?.Trim().ToLowerInvariant() switch + { + "prod" => Environments.Production, + "staging" or "edge" => Environments.Staging, + "dev" => Environments.Development, + _ => null + }; } diff --git a/src/Elastic.Documentation.ServiceDefaults/ElasticsearchEndpointFactory.cs b/src/Elastic.Documentation.ServiceDefaults/ElasticsearchEndpointFactory.cs index a0eaa00fca..8a2dd67da9 100644 --- a/src/Elastic.Documentation.ServiceDefaults/ElasticsearchEndpointFactory.cs +++ b/src/Elastic.Documentation.ServiceDefaults/ElasticsearchEndpointFactory.cs @@ -16,45 +16,31 @@ public static class ElasticsearchEndpointFactory /// Creates from user secrets and environment variables. /// Environment variables take priority over user secrets. /// - public static DocumentationEndpoints Create(IConfiguration? appConfiguration = null, string? buildType = null, string? environment = null) + public static DocumentationEndpoints Create( + IConfiguration? appConfiguration = null, + string? buildType = null, + string? environment = null + ) { var configBuilder = new ConfigurationBuilder(); _ = configBuilder.AddUserSecrets(UserSecretsId); _ = configBuilder.AddEnvironmentVariables(); var config = configBuilder.Build(); - var url = - config["DOCUMENTATION_ELASTIC_URL"] - ?? config["Parameters:ElasticsearchUrl"]; + var url = config["DOCUMENTATION_ELASTIC_URL"] ?? config["Parameters:ElasticsearchUrl"]; - var apiKey = - config["DOCUMENTATION_ELASTIC_APIKEY"] - ?? config["Parameters:ElasticsearchApiKey"]; + var apiKey = config["DOCUMENTATION_ELASTIC_APIKEY"] ?? config["Parameters:ElasticsearchApiKey"]; - var password = - config["DOCUMENTATION_ELASTIC_PASSWORD"] - ?? config["Parameters:ElasticsearchPassword"]; + var password = config["DOCUMENTATION_ELASTIC_PASSWORD"] ?? config["Parameters:ElasticsearchPassword"]; - var username = - config["DOCUMENTATION_ELASTIC_USERNAME"] - ?? config["Parameters:ElasticsearchUsername"] - ?? "elastic"; + var username = config["DOCUMENTATION_ELASTIC_USERNAME"] ?? config["Parameters:ElasticsearchUsername"] ?? "elastic"; if (string.IsNullOrEmpty(url)) { - return new DocumentationEndpoints - { - Elasticsearch = new ElasticsearchEndpoint { Uri = new Uri("http://localhost:9200") } - }; + return new DocumentationEndpoints { Elasticsearch = new ElasticsearchEndpoint { Uri = new Uri("http://localhost:9200") } }; } - var endpoint = new ElasticsearchEndpoint - { - Uri = new Uri(url), - ApiKey = apiKey, - Password = password, - Username = username - }; + var endpoint = new ElasticsearchEndpoint { Uri = new Uri(url), ApiKey = apiKey, Password = password, Username = username }; buildType ??= appConfiguration?["DOCS_BUILD_TYPE"] ?? config["DOCS_BUILD_TYPE"] ?? "isolated"; IEnvironmentValidator environmentValidator = buildType == "codex" @@ -62,9 +48,7 @@ public static DocumentationEndpoints Create(IConfiguration? appConfiguration = n : new SiteEnvironmentValidator(); environment ??= environmentValidator.Resolve(appConfiguration?["ENVIRONMENT"] ?? config["ENVIRONMENT"]); - var searchIndexOverride = - config["DOCUMENTATION_ELASTIC_INDEX_OVERRIDE"] - ?? config["Parameters:ElasticsearchIndexOverride"]; + var searchIndexOverride = config["DOCUMENTATION_ELASTIC_INDEX_OVERRIDE"] ?? config["Parameters:ElasticsearchIndexOverride"]; return new DocumentationEndpoints { diff --git a/src/Elastic.Documentation.ServiceDefaults/Logging/CondensedConsoleLogger.cs b/src/Elastic.Documentation.ServiceDefaults/Logging/CondensedConsoleLogger.cs index a69842c767..534039eacc 100644 --- a/src/Elastic.Documentation.ServiceDefaults/Logging/CondensedConsoleLogger.cs +++ b/src/Elastic.Documentation.ServiceDefaults/Logging/CondensedConsoleLogger.cs @@ -11,9 +11,7 @@ namespace Elastic.Documentation.ServiceDefaults.Logging; public class CondensedConsoleFormatter() : ConsoleFormatter("condensed") { - public override void Write( - in LogEntry logEntry, IExternalScopeProvider? scopeProvider, TextWriter textWriter - ) + public override void Write(in LogEntry logEntry, IExternalScopeProvider? scopeProvider, TextWriter textWriter) { var now = DateTime.UtcNow; var message = logEntry.Formatter.Invoke(logEntry.State, logEntry.Exception); @@ -21,8 +19,7 @@ public override void Write( var logLevel = GetLogLevel(logEntry.LogLevel); var categoryName = logEntry.Category; - var nowString = - Environment.UserInteractive + var nowString = Environment.UserInteractive ? "" : now.ToString("[yyyy-MM-ddTHH:mm:ss.fffZ] ", System.Globalization.CultureInfo.InvariantCulture); diff --git a/src/Elastic.Documentation.ServiceDefaults/Logging/EuidLogProcessor.cs b/src/Elastic.Documentation.ServiceDefaults/Logging/EuidLogProcessor.cs index 511fe2677e..219509e4ea 100644 --- a/src/Elastic.Documentation.ServiceDefaults/Logging/EuidLogProcessor.cs +++ b/src/Elastic.Documentation.ServiceDefaults/Logging/EuidLogProcessor.cs @@ -19,8 +19,7 @@ public class EuidLogProcessor : BaseProcessor public override void OnEnd(LogRecord logRecord) { // Check if euid already exists as an attribute - var hasEuidAttribute = logRecord.Attributes?.Any(a => - a.Key == TelemetryConstants.UserEuidAttributeName) ?? false; + var hasEuidAttribute = logRecord.Attributes?.Any(a => a.Key == TelemetryConstants.UserEuidAttributeName) ?? false; if (hasEuidAttribute) { diff --git a/src/Elastic.Documentation.ServiceDefaults/Telemetry/OpenTelemetryRegistrationExtensions.cs b/src/Elastic.Documentation.ServiceDefaults/Telemetry/OpenTelemetryRegistrationExtensions.cs index 2697f5c1e2..8eec642575 100644 --- a/src/Elastic.Documentation.ServiceDefaults/Telemetry/OpenTelemetryRegistrationExtensions.cs +++ b/src/Elastic.Documentation.ServiceDefaults/Telemetry/OpenTelemetryRegistrationExtensions.cs @@ -30,8 +30,10 @@ public static class OpenTelemetryRegistrationExtensions /// No-ops if OTEL_EXPORTER_OTLP_ENDPOINT is not set. /// /// The builder for chaining - public static TBuilder AddDocumentationOpenTelemetry(this TBuilder builder, OtelRegistration registration) - where TBuilder : IHostApplicationBuilder + public static TBuilder AddDocumentationOpenTelemetry( + this TBuilder builder, + OtelRegistration registration + ) where TBuilder : IHostApplicationBuilder { var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); if (!useOtlpExporter) @@ -49,63 +51,76 @@ public static TBuilder AddDocumentationOpenTelemetry(this TBuilder bui // Configure delta temporality for Elasticsearch compatibility // See: https://www.elastic.co/docs/reference/opentelemetry/compatibility/limitations#histograms-in-delta-temporality-only - _ = builder.Services.Configure(mo => - { - mo.TemporalityPreference = MetricReaderTemporalityPreference.Delta; - }); - _ = builder.Services.Configure(logging => - { - logging.IncludeFormattedMessage = true; - logging.IncludeScopes = true; - }); + _ = + builder.Services.Configure(mo => + { + mo.TemporalityPreference = MetricReaderTemporalityPreference.Delta; + }); + _ = + builder.Services.Configure(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); - _ = builder.AddElasticOpenTelemetry(options, edotBuilder => - { - _ = edotBuilder.ConfigureResource(r => ResourceBuilderExtensions.AddService(r, serviceName: serviceName, serviceVersion: VersionHelper.InformationalVersion) - .AddAttributes(new Dictionary - { - ["deployment.environment"] = !string.IsNullOrWhiteSpace(environment) ? environment : builder.Environment.EnvironmentName, - }) - ); - _ = edotBuilder - .WithLogging(logging => - { - _ = logging.AddProcessor(); - registration.Logging?.Invoke(options, logging); - }) - .WithTracing(tracing => + _ = + builder.AddElasticOpenTelemetry( + options, + edotBuilder => { - _ = tracing - .AddSource(builder.Environment.ApplicationName) - .AddAspNetCoreInstrumentation(aspNetCoreOptions => + _ = + edotBuilder.ConfigureResource( + r => + ResourceBuilderExtensions.AddService( + r, + serviceName: serviceName, + serviceVersion: VersionHelper.InformationalVersion + ).AddAttributes(new Dictionary + { + ["deployment.environment"] = !string.IsNullOrWhiteSpace(environment) + ? environment + : builder.Environment.EnvironmentName, + }) + ); + _ = + edotBuilder.WithLogging(logging => { - // Exclude requests from our own synthetics monitors from tracing - aspNetCoreOptions.Filter = httpContext => - !httpContext.Request.Headers.ContainsKey(TelemetryConstants.SyntheticMonitorHeaderName); - // Enrich spans with custom attributes from HTTP context - aspNetCoreOptions.EnrichWithHttpRequest = (activity, httpRequest) => - { - // Add euid cookie value to span attributes and baggage - if (!httpRequest.Cookies.TryGetValue("euid", out var euid) || string.IsNullOrEmpty(euid)) - return; - _ = activity.SetTag(TelemetryConstants.UserEuidAttributeName, euid); - // Add to baggage so it propagates to all child spans - _ = activity.AddBaggage(TelemetryConstants.UserEuidAttributeName, euid); - }; + _ = logging.AddProcessor(); + registration.Logging?.Invoke(options, logging); }) - .AddProcessor() // Automatically add euid to all child spans - .AddHttpClientInstrumentation(); - registration.Tracing?.Invoke(options, tracing); - }) - .WithMetrics(metrics => - { - _ = metrics - .AddAspNetCoreInstrumentation() - .AddRuntimeInstrumentation() - .AddHttpClientInstrumentation(); - registration.Metrics?.Invoke(options, metrics); - }); - }); + .WithTracing(tracing => + { + _ = + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(aspNetCoreOptions => + { + // Exclude requests from our own synthetics monitors from tracing + aspNetCoreOptions.Filter = + httpContext => + !httpContext.Request.Headers.ContainsKey(TelemetryConstants.SyntheticMonitorHeaderName); + // Enrich spans with custom attributes from HTTP context + aspNetCoreOptions.EnrichWithHttpRequest = (activity, httpRequest) => + { + // Add euid cookie value to span attributes and baggage + if (!httpRequest.Cookies.TryGetValue("euid", out var euid) || string.IsNullOrEmpty(euid)) + return; + _ = activity.SetTag(TelemetryConstants.UserEuidAttributeName, euid); + // Add to baggage so it propagates to all child spans + _ = activity.AddBaggage(TelemetryConstants.UserEuidAttributeName, euid); + }; + }) + .AddProcessor() // Automatically add euid to all child spans + + .AddHttpClientInstrumentation(); + registration.Tracing?.Invoke(options, tracing); + }) + .WithMetrics(metrics => + { + _ = metrics.AddAspNetCoreInstrumentation().AddRuntimeInstrumentation().AddHttpClientInstrumentation(); + registration.Metrics?.Invoke(options, metrics); + }); + } + ); return builder; } diff --git a/src/Elastic.Documentation.ServiceDefaults/Telemetry/VersionHelper.cs b/src/Elastic.Documentation.ServiceDefaults/Telemetry/VersionHelper.cs index 164730e1bc..7e3b105169 100644 --- a/src/Elastic.Documentation.ServiceDefaults/Telemetry/VersionHelper.cs +++ b/src/Elastic.Documentation.ServiceDefaults/Telemetry/VersionHelper.cs @@ -29,8 +29,6 @@ private static string ParseAssemblyInformationalVersion(string? informationalVer */ var indexOfPlusSign = informationalVersion.IndexOf('+'); - return indexOfPlusSign > 0 - ? informationalVersion[..indexOfPlusSign] - : informationalVersion; + return indexOfPlusSign > 0 ? informationalVersion[..indexOfPlusSign] : informationalVersion; } } diff --git a/src/Elastic.Documentation.Site/FileProviders/EmbeddedOrPhysicalFileProvider.cs b/src/Elastic.Documentation.Site/FileProviders/EmbeddedOrPhysicalFileProvider.cs index d3fc5fb9eb..6214aef310 100644 --- a/src/Elastic.Documentation.Site/FileProviders/EmbeddedOrPhysicalFileProvider.cs +++ b/src/Elastic.Documentation.Site/FileProviders/EmbeddedOrPhysicalFileProvider.cs @@ -10,7 +10,10 @@ namespace Elastic.Documentation.Site.FileProviders; public sealed class EmbeddedOrPhysicalFileProvider : IFileProvider, IDisposable { - private readonly EmbeddedFileProvider _embeddedProvider = new(typeof(EmbeddedOrPhysicalFileProvider).Assembly, "Elastic.Documentation.Site._static"); + private readonly EmbeddedFileProvider _embeddedProvider = new( + typeof(EmbeddedOrPhysicalFileProvider).Assembly, + "Elastic.Documentation.Site._static" + ); private readonly PhysicalFileProvider? _staticFilesInDocsFolder; private readonly PhysicalFileProvider? _staticWebFilesDuringDebug; diff --git a/src/Elastic.Documentation.Site/FileProviders/Preloader.cs b/src/Elastic.Documentation.Site/FileProviders/Preloader.cs index 743218dbb3..5b238cf306 100644 --- a/src/Elastic.Documentation.Site/FileProviders/Preloader.cs +++ b/src/Elastic.Documentation.Site/FileProviders/Preloader.cs @@ -14,7 +14,8 @@ public static partial class FontPreloader // For development: clear cache when needed public static void ClearCache() => FontUriCache = null; - public static async Task> GetFontUrisAsync(string? urlPrefix) => FontUriCache ??= await LoadFontUrisAsync(urlPrefix); + public static async Task> GetFontUrisAsync(string? urlPrefix) => + FontUriCache ??= await LoadFontUrisAsync(urlPrefix); private static async Task> LoadFontUrisAsync(string? urlPrefix) { var cachedFontUris = new List(); diff --git a/src/Elastic.Documentation.Site/Navigation/INavigationHtmlWriter.cs b/src/Elastic.Documentation.Site/Navigation/INavigationHtmlWriter.cs index bd08b71c66..ccd26aba4e 100644 --- a/src/Elastic.Documentation.Site/Navigation/INavigationHtmlWriter.cs +++ b/src/Elastic.Documentation.Site/Navigation/INavigationHtmlWriter.cs @@ -19,20 +19,12 @@ async Task Render(NavigationRenderModel model, Cancel ct { var slice = _TocTree.Create(model); var html = await slice.RenderAsync(cancellationToken: ctx); - return new NavigationRenderResult - { - Html = html, - Id = model.ContentHash - }; + return new NavigationRenderResult { Html = html, Id = model.ContentHash }; } } public record NavigationRenderResult { - public static NavigationRenderResult Empty { get; } = new() - { - Html = string.Empty, - Id = "empty-navigation" - }; + public static NavigationRenderResult Empty { get; } = new() { Html = string.Empty, Id = "empty-navigation" }; public required string Html { get; init; } public required string Id { get; init; } diff --git a/src/Elastic.Documentation.Site/Navigation/IsolatedBuildNavigationHtmlWriter.cs b/src/Elastic.Documentation.Site/Navigation/IsolatedBuildNavigationHtmlWriter.cs index e1b755455a..b3d6237502 100644 --- a/src/Elastic.Documentation.Site/Navigation/IsolatedBuildNavigationHtmlWriter.cs +++ b/src/Elastic.Documentation.Site/Navigation/IsolatedBuildNavigationHtmlWriter.cs @@ -8,15 +8,18 @@ namespace Elastic.Documentation.Site.Navigation; -public class IsolatedBuildNavigationHtmlWriter(BuildContext context, IRootNavigationItem siteRoot) - : INavigationHtmlWriter +public class IsolatedBuildNavigationHtmlWriter( + BuildContext context, + IRootNavigationItem siteRoot +) : INavigationHtmlWriter { private readonly NavigationRenderCache _renderedNavigationCache = new(); public async Task RenderNavigation( IRootNavigationItem currentRootNavigation, INavigationItem currentNavigationItem, - Cancel ctx = default) + Cancel ctx = default + ) { var renderRoot = currentNavigationItem.FindIslandRoot() ?? SelectNavigationRoot(currentRootNavigation); @@ -25,7 +28,8 @@ public async Task RenderNavigation( return await _renderedNavigationCache.GetOrRenderAsync( renderRoot, - () => ((INavigationHtmlWriter)this).Render(CreateNavigationModel(group), ctx)); + () => ((INavigationHtmlWriter)this).Render(CreateNavigationModel(group), ctx) + ); } /// @@ -34,7 +38,8 @@ public async Task RenderNavigation( /// or when primary nav/dropdown features are enabled. /// private IRootNavigationItem SelectNavigationRoot( - IRootNavigationItem requestedRoot) + IRootNavigationItem requestedRoot + ) { var useRequestedRoot = requestedRoot != siteRoot || context.Configuration.Features.PrimaryNavEnabled @@ -54,6 +59,7 @@ private NavigationRenderModel CreateNavigationModel(INodeNavigationItem public sealed class NavigationRenderCache { - private readonly ConcurrentDictionary>> _cache = - new(ReferenceEqualityComparer.Instance); + private readonly ConcurrentDictionary>> _cache = new( + ReferenceEqualityComparer.Instance + ); - public async Task GetOrRenderAsync( - INavigationItem root, - Func> render) + public async Task GetOrRenderAsync(INavigationItem root, Func> render) { - var pending = _cache.GetOrAdd(root, _ => new Lazy>(render, LazyThreadSafetyMode.ExecutionAndPublication)); + var pending = _cache.GetOrAdd( + root, + _ => new Lazy>(render, LazyThreadSafetyMode.ExecutionAndPublication) + ); try { return await pending.Value.ConfigureAwait(false); diff --git a/src/Elastic.Documentation.Site/Navigation/NavigationRenderModel.cs b/src/Elastic.Documentation.Site/Navigation/NavigationRenderModel.cs index 1dd9f795ca..7ef55ac3e9 100644 --- a/src/Elastic.Documentation.Site/Navigation/NavigationRenderModel.cs +++ b/src/Elastic.Documentation.Site/Navigation/NavigationRenderModel.cs @@ -68,7 +68,8 @@ public static NavigationRenderModel Create( IEnumerable> topLevelItems, bool isUsingNavigationDropdown, bool isPrimaryNavEnabled, - bool isGlobalAssemblyBuild) + bool isGlobalAssemblyBuild + ) { var topLevel = topLevelItems.ToArray(); // Resolve current top-level by walking self-then-ancestors so nested islands @@ -110,9 +111,7 @@ public static NavigationRenderModel Create( /// Returns empty when the render root has no island ancestry (e.g. a top-level section whose /// only ancestor is the nav root, which the dropdown already replaces). /// - private static IReadOnlyList CreateBackLinks( - INavigationItem renderRoot, - bool isUsingNavigationDropdown) + private static IReadOnlyList CreateBackLinks(INavigationItem renderRoot, bool isUsingNavigationDropdown) { var immediateParent = renderRoot.Parent; if (immediateParent is null) @@ -127,7 +126,8 @@ private static IReadOnlyList CreateBackLinks( continue; var include = ReferenceEquals(ancestor, immediateParent) - || ancestor.Parent is null // top navigation root (when dropdown is off) + || ancestor.Parent is null // top navigation root (when dropdown is off) + || ancestor.RendersAsIsland(); if (!include || !seen.Add(ancestor.Url)) continue; @@ -141,7 +141,8 @@ private static IReadOnlyList CreateBackLinks( private static NavigationRenderNode? CreateRootIndex( INodeNavigationItem tree, bool isPrimaryNavEnabled, - bool isGlobalAssemblyBuild) + bool isGlobalAssemblyBuild + ) { if (tree.Index.Hidden) return null; @@ -191,7 +192,8 @@ private static IReadOnlyList CreateBackLinks( private static IEnumerable CreateNavigationItems( INodeNavigationItem parent, - bool isTopLevel) + bool isTopLevel + ) { foreach (var item in parent.NavigationItems) { diff --git a/src/Elastic.Documentation.Site/_ViewModels.cs b/src/Elastic.Documentation.Site/_ViewModels.cs index 4789119c83..c964dbf662 100644 --- a/src/Elastic.Documentation.Site/_ViewModels.cs +++ b/src/Elastic.Documentation.Site/_ViewModels.cs @@ -79,7 +79,6 @@ public TopNavRenderModel? TopNav /// public string? NavigationActiveUrl { get; init; } - // Header properties for isolated mode public string? HeaderTitle { get; init; } public string? HeaderVersion { get; init; } @@ -89,8 +88,8 @@ public TopNavRenderModel? TopNav public string? GitHubDocsUrl { get; init; } /// Full ref from GitHub Actions (e.g. refs/pull/123/merge). Set when built in a pull request workflow. public string? GitHubRef { get; init; } - public string? CanonicalUrl => CanonicalBaseUrl is not null ? - new Uri(CanonicalBaseUrl, CurrentNavigationItem.Url).ToString().TrimEnd('/') : null; + public string? CanonicalUrl => + CanonicalBaseUrl is not null ? new Uri(CanonicalBaseUrl, CurrentNavigationItem.Url).ToString().TrimEnd('/') : null; public required FeatureFlags Features { get; init; } // TODO move to @inject @@ -104,60 +103,47 @@ public TopNavRenderModel? TopNav public bool RenderHamburgerIcon { get; init; } = true; /// Whether the git remote belongs to the elastic GitHub organization. - public bool IsElasticOrg => - GitRepository?.StartsWith("elastic/", StringComparison.OrdinalIgnoreCase) == true; + public bool IsElasticOrg => GitRepository?.StartsWith("elastic/", StringComparison.OrdinalIgnoreCase) == true; /// White-label branding overrides. When non-null, all Elastic-specific chrome is suppressed. public BrandingConfiguration? Branding { get; init; } /// Static URL of the branding icon, if configured. - public string? BrandingIconStaticPath => - Branding?.Icon is { } icon ? Static(Path.GetFileName(icon)) : null; + public string? BrandingIconStaticPath => Branding?.Icon is { } icon ? Static(Path.GetFileName(icon)) : null; /// Static URL of the OG image, if configured. - public string? BrandingOgImageStaticPath => - Branding?.OgImage is { } og ? Static(Path.GetFileName(og)) : null; + public string? BrandingOgImageStaticPath => Branding?.OgImage is { } og ? Static(Path.GetFileName(og)) : null; /// Static URL of the browser favicon, if configured or auto-discovered. - public string? BrandingFaviconStaticPath => - Branding?.Favicon is { } f ? Static(Path.GetFileName(f)) : null; + public string? BrandingFaviconStaticPath => Branding?.Favicon is { } f ? Static(Path.GetFileName(f)) : null; /// Static URL of the Apple touch icon, if configured or auto-discovered. - public string? BrandingAppleTouchIconStaticPath => - Branding?.AppleTouchIcon is { } a ? Static(Path.GetFileName(a)) : null; + public string? BrandingAppleTouchIconStaticPath => Branding?.AppleTouchIcon is { } a ? Static(Path.GetFileName(a)) : null; /// Root path for static assets. For codex builds, strips the /r/repoName segment from the URL path prefix. public string StaticPathPrefix => GetStaticPathPrefix(); - private static string ApiBasePath => - SystemEnvironmentVariables.Instance.ApiPrefix; + private static string ApiBasePath => SystemEnvironmentVariables.Instance.ApiPrefix; - public FrontendConfig FrontendConfig => - BuildType switch - { - BuildType.Assembler when Features.AirGappedEnabled => - new FrontendConfig("assembler", "docs-frontend", false, StaticPathPrefix, ApiBasePath, AirGapped: true), - BuildType.Assembler => - new FrontendConfig("assembler", "docs-frontend", true, StaticPathPrefix, ApiBasePath), - BuildType.Codex => new FrontendConfig("codex", "codex-frontend", true, StaticPathPrefix, ApiBasePath), - _ => new FrontendConfig("isolated", "docs-frontend", false, StaticPathPrefix, ApiBasePath), - }; + public FrontendConfig FrontendConfig => BuildType switch + { + BuildType.Assembler when Features.AirGappedEnabled => + new FrontendConfig("assembler", "docs-frontend", false, StaticPathPrefix, ApiBasePath, AirGapped: true), + BuildType.Assembler => new FrontendConfig("assembler", "docs-frontend", true, StaticPathPrefix, ApiBasePath), + BuildType.Codex => new FrontendConfig("codex", "codex-frontend", true, StaticPathPrefix, ApiBasePath), + _ => new FrontendConfig("isolated", "docs-frontend", false, StaticPathPrefix, ApiBasePath), + }; - public string FrontendConfigJson => - JsonSerializer.Serialize(FrontendConfig, FrontendConfigJsonContext.Default.FrontendConfig); + public string FrontendConfigJson => JsonSerializer.Serialize(FrontendConfig, FrontendConfigJsonContext.Default.FrontendConfig); public string Static(string path) { var staticPath = $"_static/{path.TrimStart('/')}"; var contentHash = StaticFileContentHashProvider.GetContentHash(path.TrimStart('/')); - var fullPath = string.IsNullOrEmpty(StaticPathPrefix) - ? $"/{staticPath}" - : $"{StaticPathPrefix}/{staticPath}"; + var fullPath = string.IsNullOrEmpty(StaticPathPrefix) ? $"/{staticPath}" : $"{StaticPathPrefix}/{staticPath}"; - return string.IsNullOrEmpty(contentHash) - ? fullPath - : $"{fullPath}?v={contentHash}"; + return string.IsNullOrEmpty(contentHash) ? fullPath : $"{fullPath}?v={contentHash}"; } private string GetStaticPathPrefix() diff --git a/src/Elastic.Documentation.Svg/EuiSvgIcons.cs b/src/Elastic.Documentation.Svg/EuiSvgIcons.cs index 98e38c0858..c4565866e4 100644 --- a/src/Elastic.Documentation.Svg/EuiSvgIcons.cs +++ b/src/Elastic.Documentation.Svg/EuiSvgIcons.cs @@ -241,27 +241,21 @@ public static bool TryGetToken(string name, out string? svg) { if (IconAliases.TryGetValue(name, out var canonical)) name = canonical; - return Icons.TryGetValue(name, out var svg) - ? cssClass is not null ? InjectClass(svg, cssClass) : svg - : null; + return Icons.TryGetValue(name, out var svg) ? cssClass is not null ? InjectClass(svg, cssClass) : svg : null; } - private static string InjectClass(string svg, string cssClass) => - svg.Replace(" svg.Replace(" /// Gets a token SVG by name, returning null if not found. /// /// The token name (without .svg extension) /// The SVG content or null if not found - public static string? GetToken(string name) => - Tokens.TryGetValue(name, out var svg) ? svg : null; + public static string? GetToken(string name) => Tokens.TryGetValue(name, out var svg) ? svg : null; - private static IReadOnlyDictionary LoadIcons() => - LoadFromPrefix("svgs."); + private static IReadOnlyDictionary LoadIcons() => LoadFromPrefix("svgs."); - private static IReadOnlyDictionary LoadTokens() => - LoadFromPrefix("svgs.tokens."); + private static IReadOnlyDictionary LoadTokens() => LoadFromPrefix("svgs.tokens."); private static IReadOnlyDictionary LoadFromPrefix(string folderPrefix) { @@ -283,6 +277,7 @@ private static IReadOnlyDictionary LoadFromPrefix(string folderP }) .ToDictionary( r => r[fullPrefix.Length..^4], // Remove prefix and ".svg" suffix + r => { using var stream = assembly.GetManifestResourceStream(r); diff --git a/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs b/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs index 8b83146305..8eeb1328ea 100644 --- a/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs +++ b/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs @@ -154,10 +154,7 @@ public static class DocumentationPathsResolver /// No docset found, or no .git within MaxParents of the anchor and no /// --git-dir override. /// - public static ResolvedDocumentationPaths Resolve( - IDirectoryInfo invocation, - DocumentationScopeOptions options, - IFileSystem inner) + public static ResolvedDocumentationPaths Resolve(IDirectoryInfo invocation, DocumentationScopeOptions options, IFileSystem inner) { // 1-2. Anchor. Scoped to the invocation path only; skipped when the docset is already known. var (source, configuration) = options.ConfigurationFile is { } known @@ -191,8 +188,8 @@ public static ResolvedDocumentationPaths Resolve( // the anchor's ancestry — so this is a second instance rather than the same one. // This step uses a GitResolveFileSystem (for .git-aware scoping) because it reads FILES // inside .git/ rather than listing directories at the scope root. - var git = options.Git ?? GitCheckoutInformationFactory.Create(checkout, - new GitResolveFileSystem(source, maxParents, gitDirectories, inner)); + var git = options.Git ?? + GitCheckoutInformationFactory.Create(checkout, new GitResolveFileSystem(source, maxParents, gitDirectories, inner)); // 6. Output. Default is relative to the checkout, not the invocation. // --path repo/docs and --path repo/ must both write to repo/.artifacts, not repo/docs/.artifacts. @@ -218,8 +215,7 @@ private static (IDirectoryInfo, IFileInfo) ScanForDocset(IDirectoryInfo invocati { var scan = new DocsetScanFileSystem(invocation, inner); if (!Paths.TryFindDocsFolderFromRoot(scan, scan.DirectoryInfo.New(invocation.FullName), out var dir, out var file)) - throw new DocumentationPathException( - $"No docset.yml or _docset.yml found in '{invocation.FullName}' or any subfolder."); + throw new DocumentationPathException($"No docset.yml or _docset.yml found in '{invocation.FullName}' or any subfolder."); return (dir, file); } @@ -248,7 +244,8 @@ private static IDirectoryInfo ResolveCheckout( IDirectoryInfo source, DocumentationScopeOptions options, int maxParents, - IFileSystem inner) + IFileSystem inner + ) { if (options.GitDir is { } configured && inner.NewDirInfo(configured) is { } explicitGitDir) { @@ -256,9 +253,10 @@ private static IDirectoryInfo ResolveCheckout( throw new DocumentationPathException($"--git-dir '{explicitGitDir.FullName}' does not exist."); if (!inner.File.Exists(inner.Path.Join(explicitGitDir.FullName, "HEAD"))) throw new DocumentationPathException( - $"--git-dir '{explicitGitDir.FullName}' does not appear to be a valid .git directory (no HEAD file found)."); - return explicitGitDir.Parent - ?? throw new DocumentationPathException($"--git-dir '{explicitGitDir.FullName}' has no parent directory."); + $"--git-dir '{explicitGitDir.FullName}' does not appear to be a valid .git directory (no HEAD file found)." + ); + return explicitGitDir.Parent ?? + throw new DocumentationPathException($"--git-dir '{explicitGitDir.FullName}' has no parent directory."); } var gitRoot = Paths.FindGitRoot(gitScope.DirectoryInfo.New(source.FullName), maxParents); @@ -272,8 +270,9 @@ private static IDirectoryInfo ResolveCheckout( return source; throw new DocumentationPathException( - $"No .git found at '{source.FullName}' or within {maxParents} parent directory(ies). " - + "Pass --git-dir to point at the repository's .git directory explicitly."); + $"No .git found at '{source.FullName}' or within {maxParents} parent directory(ies). " + + "Pass --git-dir to point at the repository's .git directory explicitly." + ); } private static IReadOnlyList ResolveGitDirectories(IFileSystem gitScope, IDirectoryInfo checkout, IFileSystem inner) @@ -290,9 +289,7 @@ private static IReadOnlyList ResolveGitDirectories(IFileSystem gitScope, : []; } - private static IReadOnlyList FilterExtraRoots( - IEnumerable? extraRoots, - IDirectoryInfo checkout) + private static IReadOnlyList FilterExtraRoots(IEnumerable? extraRoots, IDirectoryInfo checkout) { if (extraRoots is null) return []; @@ -305,9 +302,11 @@ private static IReadOnlyList FilterExtraRoots( if (string.IsNullOrEmpty(root)) continue; // Drop descendants of checkout (already in scope) and ancestors (would subsume checkout). - if (!IDirectoryInfoExtensions.IsSubPath(root, checkoutPath, fs) + if ( + !IDirectoryInfoExtensions.IsSubPath(root, checkoutPath, fs) && !IDirectoryInfoExtensions.IsSubPath(checkoutPath, root, fs) - && !result.Contains(root, StringComparer.OrdinalIgnoreCase)) + && !result.Contains(root, StringComparer.OrdinalIgnoreCase) + ) { result.Add(root); } diff --git a/src/Elastic.Documentation.Tooling/Exporter.cs b/src/Elastic.Documentation.Tooling/Exporter.cs index 54b151e0cf..75aef683de 100644 --- a/src/Elastic.Documentation.Tooling/Exporter.cs +++ b/src/Elastic.Documentation.Tooling/Exporter.cs @@ -22,8 +22,23 @@ public enum Exporter public static class ExportOptions { - public static HashSet Default { get; } = [Exporter.Html, Exporter.LLMText, Exporter.Configuration, Exporter.DocumentationState, Exporter.LinkMetadata, Exporter.Redirects, Exporter.Pagefind]; - public static HashSet MetadataOnly { get; } = [Exporter.Configuration, Exporter.DocumentationState, Exporter.LinkMetadata, Exporter.Redirects]; + public static HashSet Default { get; } = + [ + Exporter.Html, + Exporter.LLMText, + Exporter.Configuration, + Exporter.DocumentationState, + Exporter.LinkMetadata, + Exporter.Redirects, + Exporter.Pagefind + ]; + public static HashSet MetadataOnly { get; } = + [ + Exporter.Configuration, + Exporter.DocumentationState, + Exporter.LinkMetadata, + Exporter.Redirects + ]; /// /// Used by the serve background validation build. HTML-only so that parsing runs diff --git a/src/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs b/src/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs index d7faa0ff0f..dc12c74be0 100644 --- a/src/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs +++ b/src/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs @@ -16,8 +16,7 @@ public readonly record struct RetryPolicy(int MaxAttempts, TimeSpan BaseDelay) { public static RetryPolicy None { get; } = new(1, TimeSpan.Zero); - public TimeSpan DelayBeforeAttempt(int attempt) => - attempt <= 1 ? TimeSpan.Zero : BaseDelay * Math.Pow(2, attempt - 2); + public TimeSpan DelayBeforeAttempt(int attempt) => attempt <= 1 ? TimeSpan.Zero : BaseDelay * Math.Pow(2, attempt - 2); } public abstract class ExternalCommandExecutor(IDiagnosticsCollector collector, IDirectoryInfo workingDirectory, TimeSpan? timeout = null) @@ -64,8 +63,14 @@ protected bool ExecInWithRetry(Dictionary environmentVars, Retry // Deliberately not routed through Log: a silent multi second stall is worse than extra local output. if (attempt < retry.MaxAttempts) { - Logger.LogWarning("[{Command}] Exit code {ExitCode}. Retrying ({Attempt}/{MaxAttempts}) in {WorkingDirectory}", - command, exitCode, attempt, retry.MaxAttempts, workingDirectory.FullName); + Logger.LogWarning( + "[{Command}] Exit code {ExitCode}. Retrying ({Attempt}/{MaxAttempts}) in {WorkingDirectory}", + command, + exitCode, + attempt, + retry.MaxAttempts, + workingDirectory.FullName + ); } } @@ -90,7 +95,10 @@ protected void ExecInSilent(Dictionary environmentVars, string b }; var result = Proc.Start(arguments); if (result.ExitCode != 0) - collector.EmitError("", $"Exit code: {result.ExitCode} while executing {binary} {string.Join(" ", args)} in {workingDirectory}"); + collector.EmitError( + "", + $"Exit code: {result.ExitCode} while executing {binary} {string.Join(" ", args)} in {workingDirectory}" + ); } protected string[] CaptureMultiple(string binary, params string[] args) => CaptureMultiple(false, 10, binary, args); @@ -115,7 +123,16 @@ private string[] CaptureMultiple(bool muteExceptions, int attempts, string binar if (e is not null && !muteExceptions) collector.EmitError("", "failure capturing stdout", e); if (e is not null) - Log(l => l.LogError(e, "[{Binary} {Args}] failure capturing stdout executing in {WorkingDirectory}", binary, string.Join(" ", args), workingDirectory.FullName)); + Log( + l => + l.LogError( + e, + "[{Binary} {Args}] failure capturing stdout executing in {WorkingDirectory}", + binary, + string.Join(" ", args), + workingDirectory.FullName + ) + ); return []; @@ -138,17 +155,40 @@ string[] CaptureOutput(Exception? previousException, int iteration, int max) output = result.ConsoleOut.Select(x => x.Line).ToArray(); if (output.Length == 0) { - Log(l => l.LogInformation("[{Binary} {Args}] captured no output. ({Iteration}/{MaxIteration}) pwd: {WorkingDirectory}", - binary, string.Join(" ", args), iteration, max, workingDirectory.FullName) + Log( + l => + l.LogInformation( + "[{Binary} {Args}] captured no output. ({Iteration}/{MaxIteration}) pwd: {WorkingDirectory}", + binary, + string.Join(" ", args), + iteration, + max, + workingDirectory.FullName + ) + ); + throw new Exception( + $"No output captured executing in pwd: {workingDirectory} from {binary} {string.Join(" ", args)}", + previousException ); - throw new Exception($"No output captured executing in pwd: {workingDirectory} from {binary} {string.Join(" ", args)}", previousException); } break; case (not 0, false): - Log(l => l.LogInformation("[{Binary} {Args}] Exit code is not 0 but {ExitCode}. ({Iteration}/{MaxIteration}) pwd: {WorkingDirectory}", - binary, string.Join(" ", args), result.ExitCode, iteration, max, workingDirectory.FullName) + Log( + l => + l.LogInformation( + "[{Binary} {Args}] Exit code is not 0 but {ExitCode}. ({Iteration}/{MaxIteration}) pwd: {WorkingDirectory}", + binary, + string.Join(" ", args), + result.ExitCode, + iteration, + max, + workingDirectory.FullName + ) + ); + throw new Exception( + $"Exit code not 0. Received {result.ExitCode} in pwd: {workingDirectory} from {binary} {string.Join(" ", args)}", + previousException ); - throw new Exception($"Exit code not 0. Received {result.ExitCode} in pwd: {workingDirectory} from {binary} {string.Join(" ", args)}", previousException); } return output; @@ -162,6 +202,8 @@ private string Capture(bool muteExceptions, int attempts, string binary, params { var lines = CaptureMultiple(muteExceptions, attempts, binary, args); return lines.FirstOrDefault() ?? - (muteExceptions ? string.Empty : throw new Exception($"[{binary} {string.Join(" ", args)}] No output captured executing in : {workingDirectory}")); + (muteExceptions + ? string.Empty + : throw new Exception($"[{binary} {string.Join(" ", args)}] No output captured executing in : {workingDirectory}")); } } diff --git a/src/Elastic.Documentation.Tooling/FileSystems/ApplicationDataFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/ApplicationDataFileSystem.cs index c52651b934..6df2e007eb 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/ApplicationDataFileSystem.cs +++ b/src/Elastic.Documentation.Tooling/FileSystems/ApplicationDataFileSystem.cs @@ -13,14 +13,13 @@ namespace Elastic.Documentation.FileSystems; /// Use for components that access caches or state and have no need for workspace files /// (e.g. CrossLinkFetcher, CheckForUpdatesFilter, GitLinkIndexReader). /// -public class ApplicationDataFileSystem(IFileSystem? inner = null) - : ScopedFileSystem( - inner ?? new FileSystem(), - new ScopedFileSystemOptions([Paths.ApplicationData.FullName]) - { - // .git needed for codex-link-index clone directory inside ApplicationData - AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } - }), - IAppDataFileSystem +public class ApplicationDataFileSystem(IFileSystem? inner = null) : ScopedFileSystem( + inner ?? new FileSystem(), + new ScopedFileSystemOptions([Paths.ApplicationData.FullName]) + { + // .git needed for codex-link-index clone directory inside ApplicationData + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } + } +), IAppDataFileSystem { } diff --git a/src/Elastic.Documentation.Tooling/FileSystems/ChangelogFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/ChangelogFileSystem.cs index 266d91f065..4bba6af6bc 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/ChangelogFileSystem.cs +++ b/src/Elastic.Documentation.Tooling/FileSystems/ChangelogFileSystem.cs @@ -13,13 +13,14 @@ namespace Elastic.Documentation.FileSystems; /// Allows reading .git metadata (remote URL, branch); does not include /// AppData or build artifacts — changelog operates only within the repo working tree. /// -public class ChangelogFileSystem(IDirectoryInfo root, IFileSystem? inner = null) - : ScopedFileSystem(inner ?? Physical, new ScopedFileSystemOptions([root.FullName]) +public class ChangelogFileSystem(IDirectoryInfo root, IFileSystem? inner = null) : ScopedFileSystem( + inner ?? Physical, + new ScopedFileSystemOptions([root.FullName]) { AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" }, AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } - }), - IChangelogFileSystem + } +), IChangelogFileSystem { private static readonly FileSystem Physical = new(); diff --git a/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs index 9273457705..47ad873b16 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs +++ b/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs @@ -23,11 +23,12 @@ public class CheckoutsFileSystem : ScopedFileSystem, ICheckoutsFileSystem private readonly IFileSystem _inner; - public CheckoutsFileSystem(IDirectoryInfo root, + public CheckoutsFileSystem( + IDirectoryInfo root, IDirectoryInfo? output = null, IFileSystem? inner = null, - IEnumerable? extraRoots = null) - : base(inner ?? Physical, BuildReadOptions(root, extraRoots)) + IEnumerable? extraRoots = null + ) : base(inner ?? Physical, BuildReadOptions(root, extraRoots)) { _inner = inner ?? Physical; Write = new DocumentationWriteFileSystem(root, output, _inner); @@ -64,7 +65,12 @@ private static ScopedFileSystemOptions BuildReadOptions(IDirectoryInfo root, IEn return new ScopedFileSystemOptions([.. roots]) { AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, - AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" } + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + ".git", + ".doc.state", + ".pagefind-net-frontend-version" + } }; } diff --git a/src/Elastic.Documentation.Tooling/FileSystems/ConfigurationFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/ConfigurationFileSystem.cs index 01d36090b5..45324dd66c 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/ConfigurationFileSystem.cs +++ b/src/Elastic.Documentation.Tooling/FileSystems/ConfigurationFileSystem.cs @@ -13,19 +13,15 @@ namespace Elastic.Documentation.FileSystems; /// per-user application data. Used by ConfigurationFileProvider, which reads /// config/*.yml and writes runtime artefacts under AppData/config-runtime. /// -public class ConfigurationFileSystem(IFileSystem? inner = null) - : ScopedFileSystem( - // ScopedFileSystem cannot wrap another ScopedFileSystem. When a ScopedFileSystem is - // supplied (e.g. a DocumentationFileSystem from a test context) use the physical FS instead, - // since config files live outside a docset scope anyway. - inner is ScopedFileSystem ? new FileSystem() : (inner ?? new FileSystem()), - new ScopedFileSystemOptions([ - System.IO.Path.Join(Paths.WorkingDirectoryRoot.FullName, "config"), - Paths.ApplicationData.FullName - ]) - { - AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } - }), - IAppDataFileSystem +public class ConfigurationFileSystem(IFileSystem? inner = null) : ScopedFileSystem( + // ScopedFileSystem cannot wrap another ScopedFileSystem. When a ScopedFileSystem is + // supplied (e.g. a DocumentationFileSystem from a test context) use the physical FS instead, + // since config files live outside a docset scope anyway. + inner is ScopedFileSystem ? new FileSystem() : (inner ?? new FileSystem()), + new ScopedFileSystemOptions([System.IO.Path.Join(Paths.WorkingDirectoryRoot.FullName, "config"), Paths.ApplicationData.FullName]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } + } +), IAppDataFileSystem { } diff --git a/src/Elastic.Documentation.Tooling/FileSystems/DocsetScanFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/DocsetScanFileSystem.cs index dd3bf65839..f7b139a790 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/DocsetScanFileSystem.cs +++ b/src/Elastic.Documentation.Tooling/FileSystems/DocsetScanFileSystem.cs @@ -16,7 +16,9 @@ namespace Elastic.Documentation.FileSystems; /// recursive fallback enumerates downward. Nothing about the docset scan needs a parent directory. /// /// -internal sealed class DocsetScanFileSystem(IDirectoryInfo path, IFileSystem? inner = null) - : ScopedFileSystem(inner ?? new FileSystem(), new ScopedFileSystemOptions([path.FullName])) +internal sealed class DocsetScanFileSystem(IDirectoryInfo path, IFileSystem? inner = null) : ScopedFileSystem( + inner ?? new FileSystem(), + new ScopedFileSystemOptions([path.FullName]) +) { } diff --git a/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs index 9ab2486fec..0783b4c948 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs +++ b/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs @@ -23,8 +23,10 @@ public class DocumentationFileSystem : ScopedFileSystem, IDocumentationFileSyste { private static readonly FileSystem Physical = new(); - private DocumentationFileSystem(ResolvedDocumentationPaths paths, IFileSystem inner, IFileSystem? innerWrite = null) - : base(inner, BuildReadOptions(paths)) + private DocumentationFileSystem(ResolvedDocumentationPaths paths, IFileSystem inner, IFileSystem? innerWrite = null) : base( + inner, + BuildReadOptions(paths) + ) { Paths = paths; Write = new DocumentationWriteFileSystem(paths.CheckoutDirectory, paths.OutputDirectory, innerWrite ?? inner); @@ -100,7 +102,12 @@ private static ScopedFileSystemOptions BuildReadOptions(ResolvedDocumentationPat return new ScopedFileSystemOptions([.. roots]) { AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, - AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" } + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + ".git", + ".doc.state", + ".pagefind-net-frontend-version" + } }; } } diff --git a/src/Elastic.Documentation.Tooling/FileSystems/GitResolveFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/GitResolveFileSystem.cs index 23280e2ab9..b0dba2f077 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/GitResolveFileSystem.cs +++ b/src/Elastic.Documentation.Tooling/FileSystems/GitResolveFileSystem.cs @@ -29,16 +29,11 @@ public GitResolveFileSystem( IDirectoryInfo anchor, int maxParents = 1, IReadOnlyList? gitDirectories = null, - IFileSystem? inner = null) - : base(inner ?? new FileSystem(), BuildOptions(anchor, maxParents, gitDirectories)) - { - } + IFileSystem? inner = null + ) : base(inner ?? new FileSystem(), BuildOptions(anchor, maxParents, gitDirectories)) { } #pragma warning restore IDE0290 - private static ScopedFileSystemOptions BuildOptions( - IDirectoryInfo anchor, - int maxParents, - IReadOnlyList? gitDirectories) + private static ScopedFileSystemOptions BuildOptions(IDirectoryInfo anchor, int maxParents, IReadOnlyList? gitDirectories) { // Walk maxParents above the anchor to get the scope root. var root = anchor; diff --git a/src/Elastic.Documentation.Tooling/FileSystems/RunnerTempFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/RunnerTempFileSystem.cs index 5160871446..bb8527316a 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/RunnerTempFileSystem.cs +++ b/src/Elastic.Documentation.Tooling/FileSystems/RunnerTempFileSystem.cs @@ -21,9 +21,8 @@ namespace Elastic.Documentation.FileSystems; public class RunnerTempFileSystem( IDirectoryInfo workingRoot, IEnumerable? ciPaths = null, - IFileSystem? inner = null) - : ScopedFileSystem(inner ?? Physical, BuildOptions(workingRoot, ciPaths)), - IRunnerTempFileSystem + IFileSystem? inner = null +) : ScopedFileSystem(inner ?? Physical, BuildOptions(workingRoot, ciPaths)), IRunnerTempFileSystem { private static readonly FileSystem Physical = new(); @@ -59,9 +58,7 @@ public static RunnerTempFileSystem ForEvaluatePr(IEnvironmentVariables env, IFil var fs = inner ?? Physical; var workingRoot = fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); var runnerTemp = env.GetEnvironmentVariable("RUNNER_TEMP"); - return new RunnerTempFileSystem(workingRoot, - ciPaths: string.IsNullOrWhiteSpace(runnerTemp) ? null : [runnerTemp], - inner: inner); + return new RunnerTempFileSystem(workingRoot, ciPaths: string.IsNullOrWhiteSpace(runnerTemp) ? null : [runnerTemp], inner: inner); } public static RunnerTempFileSystem ForEvaluateArtifact(string metadataPath, IFileSystem? inner = null) @@ -69,9 +66,7 @@ public static RunnerTempFileSystem ForEvaluateArtifact(string metadataPath, IFil var fs = inner ?? Physical; var workingRoot = fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); var metadataDir = System.IO.Path.GetDirectoryName(metadataPath); - return new RunnerTempFileSystem(workingRoot, - ciPaths: string.IsNullOrWhiteSpace(metadataDir) ? null : [metadataDir], - inner: inner); + return new RunnerTempFileSystem(workingRoot, ciPaths: string.IsNullOrWhiteSpace(metadataDir) ? null : [metadataDir], inner: inner); } public static RunnerTempFileSystem ForPrepareArtifact(string? stagingDir, string? outputDir, IFileSystem? inner = null) diff --git a/src/Elastic.Documentation.Tooling/GitCheckoutInformationFactory.cs b/src/Elastic.Documentation.Tooling/GitCheckoutInformationFactory.cs index 3d6fdafde4..25c81a797c 100644 --- a/src/Elastic.Documentation.Tooling/GitCheckoutInformationFactory.cs +++ b/src/Elastic.Documentation.Tooling/GitCheckoutInformationFactory.cs @@ -60,8 +60,7 @@ private static GitCheckoutInformation TryCreate(IDirectoryInfo source, IFileSyst else { var gitFile = fileSystem.FileInfo.New(gitDirPath); - if (!Paths.TryReadGitDirPointer(fileSystem, gitFile, out var resolvedGitDir) - || resolvedGitDir is null) + if (!Paths.TryReadGitDirPointer(fileSystem, gitFile, out var resolvedGitDir) || resolvedGitDir is null) return GitCheckoutInformation.Unavailable; gitDir = resolvedGitDir; @@ -75,9 +74,7 @@ private static GitCheckoutInformation TryCreate(IDirectoryInfo source, IFileSyst } var headPath = fileSystem.Path.Join(gitDir.FullName, "HEAD"); - var headText = fileSystem.File.Exists(headPath) - ? fileSystem.File.ReadAllText(headPath).Trim() - : null; + var headText = fileSystem.File.Exists(headPath) ? fileSystem.File.ReadAllText(headPath).Trim() : null; if (headText is null) return GitCheckoutInformation.Unavailable; @@ -93,6 +90,7 @@ private static GitCheckoutInformation TryCreate(IDirectoryInfo source, IFileSyst gitRef = fileSystem.File.ReadAllText(refFilePath).Trim(); else if (TryResolvePackedRef(fileSystem, gitDir, refPath, out var packedSha)) gitRef = packedSha; // loose ref file absent — ref lives in packed-refs instead + else gitRef = headText; // symbolic ref not yet written (new empty repo) — use the ref name itself } @@ -100,9 +98,10 @@ private static GitCheckoutInformation TryCreate(IDirectoryInfo source, IFileSyst { // Detached HEAD: raw SHA gitRef = headText; - branch = Environment.GetEnvironmentVariable("GITHUB_PR_REF_NAME") - ?? Environment.GetEnvironmentVariable("GITHUB_REF_NAME") - ?? "detached/head"; + branch = + Environment.GetEnvironmentVariable("GITHUB_PR_REF_NAME") + ?? Environment.GetEnvironmentVariable("GITHUB_REF_NAME") + ?? "detached/head"; } var ini = new IniFile(); @@ -168,8 +167,7 @@ private static bool IsLegacyTestWithoutGitLayout(IFileSystem fileSystem, IDirect { var gitPath = fileSystem.Path.Join(source.FullName, ".git"); var noGitEntry = !fileSystem.Directory.Exists(gitPath) && !fileSystem.File.Exists(gitPath); - var gitDirWithoutConfig = fileSystem.Directory.Exists(gitPath) - && !fileSystem.File.Exists(fileSystem.Path.Join(gitPath, "config")); + var gitDirWithoutConfig = fileSystem.Directory.Exists(gitPath) && !fileSystem.File.Exists(fileSystem.Path.Join(gitPath, "config")); return noGitEntry || gitDirWithoutConfig; } diff --git a/src/Elastic.Documentation.Tooling/Paths.cs b/src/Elastic.Documentation.Tooling/Paths.cs index 2a19407cb6..557c6b246a 100644 --- a/src/Elastic.Documentation.Tooling/Paths.cs +++ b/src/Elastic.Documentation.Tooling/Paths.cs @@ -43,8 +43,7 @@ public static class Paths bool hasGit; try { - hasGit = directory.GetDirectories(".git").Length > 0 - || directory.GetFiles(".git").Length > 0; + hasGit = directory.GetDirectories(".git").Length > 0 || directory.GetFiles(".git").Length > 0; } catch (DirectoryNotFoundException) { @@ -102,8 +101,7 @@ private static DirectoryInfo DetermineWorkingDirectoryRoot() // directory, which is several levels below the solution root). if (directory.GetFiles("*.slnx").Length > 0) return directory; - var hasGit = directory.GetDirectories(".git").Length > 0 - || directory.GetFiles(".git").Length > 0; + var hasGit = directory.GetDirectories(".git").Length > 0 || directory.GetFiles(".git").Length > 0; if (hasGit) { if (depth <= 1) @@ -169,10 +167,9 @@ public static bool TryFindDocsFolderFromKnownLocationsOnly( { string[] files = ["docset.yml", "_docset.yml"]; string[] knownFolders = [rootPath.FullName, Path.Join(rootPath.FullName, "docs")]; - var mostLikelyTargets = - from file in files - from folder in knownFolders - select Path.Join(folder, file); + var mostLikelyTargets = from file in files + from folder in knownFolders + select Path.Join(folder, file); return mostLikelyTargets.FirstOrDefault(readFileSystem.File.Exists); } @@ -184,10 +181,9 @@ public static (IDirectoryInfo, IFileInfo) FindDocsFolderFromRoot(IFileSystem rea if (configurationPath is not null) return (configurationPath.Directory!, configurationPath); - configurationPath = rootPath - .EnumerateFiles("*docset.yml", SearchOption.AllDirectories) - .FirstOrDefault() - ?? throw new Exception($"Can not locate docset.yml file in '{rootPath}'"); + configurationPath = + rootPath.EnumerateFiles("*docset.yml", SearchOption.AllDirectories).FirstOrDefault() ?? + throw new Exception($"Can not locate docset.yml file in '{rootPath}'"); var docsFolder = configurationPath.Directory ?? throw new Exception($"Can not locate docset.yml file in '{rootPath}'"); diff --git a/src/Elastic.Documentation/AppliesTo/Applicability.cs b/src/Elastic.Documentation/AppliesTo/Applicability.cs index a2f29760d1..5c024cca4a 100644 --- a/src/Elastic.Documentation/AppliesTo/Applicability.cs +++ b/src/Elastic.Documentation/AppliesTo/Applicability.cs @@ -55,10 +55,9 @@ public static bool TryParse(string? value, IList<(Severity, string)> diagnostics private static List InferVersionSemantics(List applications) { // Get items with actual GreaterThanOrEqual versions (not AllVersionsSpec, not null, not ranges/exact) - var gteItems = applications - .Where(a => a.Version is { Kind: VersionSpecKind.GreaterThanOrEqual } - && a.Version != AllVersionsSpec.Instance) - .ToList(); + var gteItems = applications.Where( + a => a.Version is { Kind: VersionSpecKind.GreaterThanOrEqual } && a.Version != AllVersionsSpec.Instance + ).ToList(); // If 0 or 1 GTE items, no inference needed if (gteItems.Count <= 1) @@ -69,11 +68,7 @@ private static List InferVersionSemantics(List app return applications; // Sort GTE items by version ascending to process from lowest to highest - var sortedGteVersions = gteItems - .Select(a => a.Version!.Min) - .Distinct() - .OrderBy(v => v) - .ToList(); + var sortedGteVersions = gteItems.Select(a => a.Version!.Min).Distinct().OrderBy(v => v).ToList(); if (sortedGteVersions.Count <= 1) return applications; @@ -94,8 +89,7 @@ private static List InferVersionSemantics(List app var nextVersion = sortedGteVersions[i + 1]; // Define an Exact or Range VersionSpec according to the numeric difference between lifecycles - if (currentVersion.Major == nextVersion.Major - && nextVersion.Minor == currentVersion.Minor + 1) + if (currentVersion.Major == nextVersion.Major && nextVersion.Minor == currentVersion.Minor + 1) versionMapping[currentVersion] = VersionSpec.Exact(currentVersion); else { @@ -133,23 +127,21 @@ public virtual bool Equals(AppliesCollection? other) public override int GetHashCode() { var comparer = StructuralComparisons.StructuralEqualityComparer; - return - (EqualityComparer.Default.GetHashCode(EqualityContract) * -1521134295) - + comparer.GetHashCode(_items); + return (EqualityComparer.Default.GetHashCode(EqualityContract) * -1521134295) + comparer.GetHashCode(_items); } - public static explicit operator AppliesCollection(string b) { var diagnostics = new List<(Severity, string)>(); var productAvailability = TryParse(b, diagnostics, out var version) ? version : null; if (diagnostics.Count > 0) - throw new ArgumentException("Explicit conversion from string to AppliesCollection failed." + string.Join(Environment.NewLine, diagnostics)); + throw new ArgumentException( + "Explicit conversion from string to AppliesCollection failed." + string.Join(Environment.NewLine, diagnostics) + ); return productAvailability ?? throw new ArgumentException($"'{b}' is not a valid applicability string array."); } - public static AppliesCollection GenerallyAvailable { get; } - = new([Applicability.GenerallyAvailable]); + public static AppliesCollection GenerallyAvailable { get; } = new([Applicability.GenerallyAvailable]); public override string ToString() { @@ -179,10 +171,7 @@ public record Applicability : IComparable, IComparable Version = AllVersionsSpec.Instance }; - - public string GetLifeCycleName() => - ProductLifecycleInfo.GetShortName(Lifecycle); - + public string GetLifeCycleName() => ProductLifecycleInfo.GetShortName(Lifecycle); /// public int CompareTo(Applicability? other) @@ -195,7 +184,7 @@ public int CompareTo(Applicability? other) if (xIsNonVersioned) return -1; // Non-versioned items sort last if (yIsNonVersioned) - return 1; // Non-versioned items sort last + return 1; // Non-versioned items sort last return Version!.CompareTo(other!.Version); } @@ -231,9 +220,13 @@ public override string ToString() public static explicit operator Applicability(string b) { var diagnostics = new List<(Severity, string)>(); - var productAvailability = TryParse(b, diagnostics, out var version) ? version : TryParse(b + ".0", diagnostics, out version) ? version : null; + var productAvailability = TryParse(b, diagnostics, out var version) + ? version + : TryParse(b + ".0", diagnostics, out version) ? version : null; if (diagnostics.Count > 0) - throw new ArgumentException("Explicit conversion from string to AppliesCollection failed." + string.Join(Environment.NewLine, diagnostics)); + throw new ArgumentException( + "Explicit conversion from string to AppliesCollection failed." + string.Join(Environment.NewLine, diagnostics) + ); return productAvailability ?? throw new ArgumentException($"'{b}' is not a valid applicability string."); } @@ -262,7 +255,6 @@ public static bool TryParse(string? value, IList<(Severity, string)> diagnostics "ga" => ProductLifecycle.GenerallyAvailable, "deprecated" => ProductLifecycle.Deprecated, "removed" => ProductLifecycle.Removed, - // OBSOLETE should be removed once docs are cleaned up "unavailable" => ProductLifecycle.Unavailable, "dev" => ProductLifecycle.Development, @@ -274,16 +266,13 @@ public static bool TryParse(string? value, IList<(Severity, string)> diagnostics }; if (lifecycle is null) { - diagnostics.Add((Severity.Error, $"Unknown product lifecycle: '{tokens[0]}'. Valid lifecycles are: ga, preview, tech-preview, experimental, beta, deprecated, removed.")); + diagnostics.Add( + (Severity.Error, $"Unknown product lifecycle: '{tokens[0]}'. Valid lifecycles are: ga, preview, tech-preview, experimental, beta, deprecated, removed.") + ); availability = null; return false; } - var deprecatedLifecycles = new[] - { - ProductLifecycle.Development, - ProductLifecycle.Planned, - ProductLifecycle.Discontinued - }; + var deprecatedLifecycles = new[] { ProductLifecycle.Development, ProductLifecycle.Planned, ProductLifecycle.Discontinued }; // TODO emit as error when all docs have been updated if (deprecatedLifecycles.Contains(lifecycle.Value)) @@ -302,7 +291,8 @@ public static bool TryParse(string? value, IList<(Severity, string)> diagnostics return true; } - public static bool operator <(Applicability? left, Applicability? right) => left is null ? right is not null : left.CompareTo(right) < 0; + public static bool operator <(Applicability? left, Applicability? right) => + left is null ? right is not null : left.CompareTo(right) < 0; public static bool operator <=(Applicability? left, Applicability? right) => left is null || left.CompareTo(right) <= 0; @@ -310,4 +300,3 @@ public static bool TryParse(string? value, IList<(Severity, string)> diagnostics public static bool operator >=(Applicability? left, Applicability? right) => left is null ? right is null : left.CompareTo(right) >= 0; } - diff --git a/src/Elastic.Documentation/AppliesTo/ApplicabilitySelector.cs b/src/Elastic.Documentation/AppliesTo/ApplicabilitySelector.cs index aa70d28259..f5caede48a 100644 --- a/src/Elastic.Documentation/AppliesTo/ApplicabilitySelector.cs +++ b/src/Elastic.Documentation/AppliesTo/ApplicabilitySelector.cs @@ -19,24 +19,24 @@ public static class ApplicabilitySelector /// The most relevant applicability for display public static Applicability GetPrimaryApplicability(IReadOnlyCollection applicabilities, SemVersion currentVersion) { - var availableApplicabilities = applicabilities - .Where(a => a.Version is null || a.Version is AllVersionsSpec || a.Version.Min <= currentVersion).ToArray(); + var availableApplicabilities = applicabilities.Where( + a => a.Version is null || a.Version is AllVersionsSpec || a.Version.Min <= currentVersion + ).ToArray(); if (availableApplicabilities.Length > 0) { - return availableApplicabilities - .OrderByDescending(a => a.Version?.Min ?? ZeroVersion.Instance) + return availableApplicabilities.OrderByDescending(a => a.Version?.Min ?? ZeroVersion.Instance) .ThenBy(a => ProductLifecycleInfo.GetOrder(a.Lifecycle)) .First(); } - var futureApplicabilities = applicabilities - .Where(a => a.Version is not null && a.Version is not AllVersionsSpec && a.Version.Min > currentVersion).ToArray(); + var futureApplicabilities = applicabilities.Where( + a => a.Version is not null && a.Version is not AllVersionsSpec && a.Version.Min > currentVersion + ).ToArray(); if (futureApplicabilities.Length > 0) { - return futureApplicabilities - .OrderBy(a => a.Version!.Min.CompareTo(currentVersion)) + return futureApplicabilities.OrderBy(a => a.Version!.Min.CompareTo(currentVersion)) .ThenBy(a => ProductLifecycleInfo.GetOrder(a.Lifecycle)) .First(); } diff --git a/src/Elastic.Documentation/AppliesTo/ApplicableTo.cs b/src/Elastic.Documentation/AppliesTo/ApplicableTo.cs index 02a44a0f8f..6488beac22 100644 --- a/src/Elastic.Documentation/AppliesTo/ApplicableTo.cs +++ b/src/Elastic.Documentation/AppliesTo/ApplicableTo.cs @@ -261,10 +261,7 @@ public record ServerlessProjectApplicability /// /// Returns if all projects share the same applicability /// - public AppliesCollection? AllProjects => - Elasticsearch == Observability && Observability == Security - ? Elasticsearch - : null; + public AppliesCollection? AllProjects => Elasticsearch == Observability && Observability == Security ? Elasticsearch : null; public static ServerlessProjectApplicability All { get; } = new() { diff --git a/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs b/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs index 23333d6720..44078a3e14 100644 --- a/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs +++ b/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs @@ -131,7 +131,9 @@ public class ApplicableToJsonConverter : JsonConverter Self = deploymentProps.TryGetValue("self", out var self) ? new AppliesCollection(self.ToArray()) : null, Ece = deploymentProps.TryGetValue("ece", out var ece) ? new AppliesCollection(ece.ToArray()) : null, Eck = deploymentProps.TryGetValue("eck", out var eck) ? new AppliesCollection(eck.ToArray()) : null, - Ess = deploymentProps.TryGetValue("ech", out var ess) || deploymentProps.TryGetValue("ess", out ess) ? new AppliesCollection(ess.ToArray()) : null + Ess = deploymentProps.TryGetValue("ech", out var ess) || deploymentProps.TryGetValue("ess", out ess) + ? new AppliesCollection(ess.ToArray()) + : null }; } @@ -154,8 +156,9 @@ public class ApplicableToJsonConverter : JsonConverter foreach (var (key, items) in productProps) { - var property = productType.GetProperties() - .FirstOrDefault(p => p.GetCustomAttribute()?.Name == key); + var property = productType.GetProperties().FirstOrDefault( + p => p.GetCustomAttribute()?.Name == key + ); property?.SetValue(productApplicability, new AppliesCollection(items.ToArray())); } diff --git a/src/Elastic.Documentation/AppliesTo/ProductLifecycleInfo.cs b/src/Elastic.Documentation/AppliesTo/ProductLifecycleInfo.cs index b3e2deec63..76f6ac2d10 100644 --- a/src/Elastic.Documentation/AppliesTo/ProductLifecycleInfo.cs +++ b/src/Elastic.Documentation/AppliesTo/ProductLifecycleInfo.cs @@ -20,29 +20,25 @@ public sealed record LifecycleMetadata(string ShortName, string DisplayText, int /// /// Gets the metadata for a given lifecycle state. /// - public static LifecycleMetadata GetMetadata(ProductLifecycle lifecycle) => - Metadata.GetValueOrDefault(lifecycle, FallbackMetadata); + public static LifecycleMetadata GetMetadata(ProductLifecycle lifecycle) => Metadata.GetValueOrDefault(lifecycle, FallbackMetadata); /// /// Gets the short name for a lifecycle (e.g., "Preview", "Beta", "GA"). /// Used for badge CSS classes and compact display. /// - public static string GetShortName(ProductLifecycle lifecycle) => - GetMetadata(lifecycle).ShortName; + public static string GetShortName(ProductLifecycle lifecycle) => GetMetadata(lifecycle).ShortName; /// /// Gets the full display text for a lifecycle (e.g., "Generally available", "Preview"). /// Used in popover availability text. /// - public static string GetDisplayText(ProductLifecycle lifecycle) => - GetMetadata(lifecycle).DisplayText; + public static string GetDisplayText(ProductLifecycle lifecycle) => GetMetadata(lifecycle).DisplayText; /// /// Gets the sort order for a lifecycle (lower = higher priority). /// GA=0, Beta=1, Preview=2, etc. /// - public static int GetOrder(ProductLifecycle lifecycle) => - GetMetadata(lifecycle).Order; + public static int GetOrder(ProductLifecycle lifecycle) => GetMetadata(lifecycle).Order; private static readonly LifecycleMetadata FallbackMetadata = new("", "", 999); @@ -60,4 +56,3 @@ public static int GetOrder(ProductLifecycle lifecycle) => [ProductLifecycle.Discontinued] = new("Discontinued", "Discontinued", 9), }; } - diff --git a/src/Elastic.Documentation/Diagnostics/DiagnosticsChannel.cs b/src/Elastic.Documentation/Diagnostics/DiagnosticsChannel.cs index e1b2b0c3af..00c6322195 100644 --- a/src/Elastic.Documentation/Diagnostics/DiagnosticsChannel.cs +++ b/src/Elastic.Documentation/Diagnostics/DiagnosticsChannel.cs @@ -17,11 +17,7 @@ public sealed class DiagnosticsChannel : IDisposable public DiagnosticsChannel() { - var options = new UnboundedChannelOptions - { - SingleReader = true, - SingleWriter = false - }; + var options = new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }; _ctxSource = new CancellationTokenSource(); _channel = Channel.CreateUnbounded(options); } diff --git a/src/Elastic.Documentation/Diagnostics/DiagnosticsCollector.cs b/src/Elastic.Documentation/Diagnostics/DiagnosticsCollector.cs index 56ddfdbce5..48e479ef7e 100644 --- a/src/Elastic.Documentation/Diagnostics/DiagnosticsCollector.cs +++ b/src/Elastic.Documentation/Diagnostics/DiagnosticsCollector.cs @@ -7,8 +7,10 @@ namespace Elastic.Documentation.Diagnostics; -public class DiagnosticsCollector(IReadOnlyCollection outputs, TimeProvider? timeProvider = null) - : IDiagnosticsCollector +public class DiagnosticsCollector( + IReadOnlyCollection outputs, + TimeProvider? timeProvider = null +) : IDiagnosticsCollector { public DiagnosticsChannel Channel { get; } = new(); @@ -58,25 +60,29 @@ private Task EnsureStarted(Cancel cancellationToken) { if (_started is not null) return _started; - _started = Task.Run(async () => - { - _ = await Channel.WaitToWrite(cancellationToken); - _readerStarted = true; - while (!Channel.CancellationToken.IsCancellationRequested) - { - try - { - while (await Channel.Reader.WaitToReadAsync(Channel.CancellationToken)) - Drain(); - } - catch + _started = + Task.Run( + async () => { - //ignore - } - } - - Drain(); - }, cancellationToken); + _ = await Channel.WaitToWrite(cancellationToken); + _readerStarted = true; + while (!Channel.CancellationToken.IsCancellationRequested) + { + try + { + while (await Channel.Reader.WaitToReadAsync(Channel.CancellationToken)) + Drain(); + } + catch + { + //ignore + } + } + + Drain(); + }, + cancellationToken + ); return _started; } @@ -142,18 +148,15 @@ public virtual void Write(Diagnostic diagnostic) } public void Emit(Severity severity, string file, string message) => - Write(new Diagnostic - { - Severity = severity, - File = file, - Message = message - }); + Write(new Diagnostic { Severity = severity, File = file, Message = message }); - public void EmitError(string file, string message, string specificErrorMessage) => Emit(Severity.Error, file, $"{message}{Environment.NewLine}{specificErrorMessage}"); + public void EmitError(string file, string message, string specificErrorMessage) => + Emit(Severity.Error, file, $"{message}{Environment.NewLine}{specificErrorMessage}"); public void EmitError(string file, string message, Exception? e = null) { - message = message + message = + message + (e != null ? Environment.NewLine + e : string.Empty) + (e?.InnerException != null ? Environment.NewLine + e.InnerException : string.Empty); Emit(Severity.Error, file, message); @@ -176,6 +179,5 @@ public async ValueTask DisposeAsync() GC.SuppressFinalize(this); } - public void CollectUsedSubstitutionKey(ReadOnlySpan key) => - _ = InUseSubstitutionKeys.TryAdd(key.ToString(), true); + public void CollectUsedSubstitutionKey(ReadOnlySpan key) => _ = InUseSubstitutionKeys.TryAdd(key.ToString(), true); } diff --git a/src/Elastic.Documentation/Diagnostics/HintTypeExtensions.cs b/src/Elastic.Documentation/Diagnostics/HintTypeExtensions.cs index b51657db0d..52a6363d6b 100644 --- a/src/Elastic.Documentation/Diagnostics/HintTypeExtensions.cs +++ b/src/Elastic.Documentation/Diagnostics/HintTypeExtensions.cs @@ -15,6 +15,5 @@ public static class HintTypeExtensions /// The set of suppressed hint types. /// The hint type to check. /// True if the hint should be suppressed, false otherwise. - public static bool ShouldSuppress(this HashSet? suppressions, HintType hintType) => - suppressions?.Contains(hintType) == true; + public static bool ShouldSuppress(this HashSet? suppressions, HintType hintType) => suppressions?.Contains(hintType) == true; } diff --git a/src/Elastic.Documentation/Diagnostics/IDiagnosticsCollector.cs b/src/Elastic.Documentation/Diagnostics/IDiagnosticsCollector.cs index 322846abce..e6589170e1 100644 --- a/src/Elastic.Documentation/Diagnostics/IDiagnosticsCollector.cs +++ b/src/Elastic.Documentation/Diagnostics/IDiagnosticsCollector.cs @@ -76,7 +76,8 @@ async Task WaitForDrain() { throw new InvalidOperationException( "WaitForDrain called on a collector that was never started; no reader is draining the channel. " + - "Call StartAsync first or dispose the collector to drain synchronously."); + "Call StartAsync first or dispose the collector to drain synchronously." + ); } // StartAsync was called but the Task.Run delegate hasn't been picked up by the @@ -89,7 +90,8 @@ async Task WaitForDrain() if (TimeProvider.GetElapsedTime(waitStart) > TimeSpan.FromSeconds(2)) throw new InvalidOperationException( "WaitForDrain timed out waiting for the background reader to start. " + - "StartAsync was called but the reader delegate did not start within the deadline."); + "StartAsync was called but the reader delegate did not start within the deadline." + ); } } @@ -104,6 +106,4 @@ async Task WaitForDrain() throw new Exception("Could not iterate over all diagnostic messages in a timely fashion"); } } - - } diff --git a/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs b/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs index 9ca708fddb..b90562135e 100644 --- a/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs +++ b/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs @@ -73,7 +73,6 @@ public static class IDirectoryInfoExtensions private static bool? CaseSensitiveOsCheck; public static bool IsCaseSensitiveFileSystem { - get { // heuristic to determine if the OS is case-sensitive @@ -85,7 +84,6 @@ public static bool IsCaseSensitiveFileSystem var culture = CultureInfo.CurrentCulture; CaseSensitiveOsCheck = !Directory.Exists(tmp.ToUpper(culture)) || !Directory.Exists(tmp.ToLower(culture)); return CaseSensitiveOsCheck ?? false; - } catch { @@ -96,7 +94,6 @@ public static bool IsCaseSensitiveFileSystem } } - /// Validates is subdirectory of public static bool IsSubPathOf(this IDirectoryInfo directory, IDirectoryInfo parentDirectory) { @@ -111,7 +108,8 @@ public static bool IsSubPathOf(this IDirectoryInfo directory, IDirectoryInfo par if (string.Equals(parent.FullName, parentDirectory.FullName, cmp)) return true; parent = parent.Parent; - } while (parent != null); + } + while (parent != null); return false; } @@ -141,8 +139,10 @@ public static void AddDisjointRoot(this List roots, string candidate, IF for (var i = 0; i < roots.Count; i++) { if (IsSubPath(candidate, roots[i], fs)) // candidate already covered by an existing (wider) root + return; if (IsSubPath(roots[i], candidate, fs)) // candidate is wider; it supersedes the existing root + { roots[i] = candidate; return; @@ -164,13 +164,18 @@ public static bool HasParent(this IDirectoryInfo directory, string parentName, S if (string.Equals(parent.Name, parentName, comparison)) return true; parent = parent.Parent; - } while (parent != null); + } + while (parent != null); return false; } /// Gets the first , parent of - public static IDirectoryInfo? GetParent(this IDirectoryInfo directory, string parentName, StringComparison comparison = OrdinalIgnoreCase) + public static IDirectoryInfo? GetParent( + this IDirectoryInfo directory, + string parentName, + StringComparison comparison = OrdinalIgnoreCase + ) { if (string.Equals(directory.Name, parentName, comparison)) return directory; @@ -180,7 +185,8 @@ public static bool HasParent(this IDirectoryInfo directory, string parentName, S if (string.Equals(parent.Name, parentName, comparison)) return parent; parent = parent.Parent; - } while (parent != null); + } + while (parent != null); return null; } diff --git a/src/Elastic.Documentation/Extensions/IdExtensions.cs b/src/Elastic.Documentation/Extensions/IdExtensions.cs index 1711375789..67f608d7e0 100644 --- a/src/Elastic.Documentation/Extensions/IdExtensions.cs +++ b/src/Elastic.Documentation/Extensions/IdExtensions.cs @@ -9,5 +9,6 @@ namespace Elastic.Documentation.Extensions; public static class ShortId { - public static string Create(params string[] components) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("", components))))[..8]; + public static string Create(params string[] components) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("", components))))[..8]; } diff --git a/src/Elastic.Documentation/Extensions/UrlPath.cs b/src/Elastic.Documentation/Extensions/UrlPath.cs index 8d5e8026d3..bb76c122ba 100644 --- a/src/Elastic.Documentation/Extensions/UrlPath.cs +++ b/src/Elastic.Documentation/Extensions/UrlPath.cs @@ -22,6 +22,5 @@ public static string Join(string left, string right) => /// Joins two path segments with a single '/', trimming duplicate slashes at the join point. /// Assumes both segments already use forward slashes. /// - public static string JoinUrl(string left, string right) => - $"{left.TrimEnd('/')}/{right.TrimStart('/')}"; + public static string JoinUrl(string left, string right) => $"{left.TrimEnd('/')}/{right.TrimStart('/')}"; } diff --git a/src/Elastic.Documentation/FileSystems/AssemblyWriteFileSystem.cs b/src/Elastic.Documentation/FileSystems/AssemblyWriteFileSystem.cs index f2a01d093a..2bf9b0876d 100644 --- a/src/Elastic.Documentation/FileSystems/AssemblyWriteFileSystem.cs +++ b/src/Elastic.Documentation/FileSystems/AssemblyWriteFileSystem.cs @@ -27,11 +27,10 @@ namespace Elastic.Documentation.FileSystems; /// The underlying filesystem. Defaults to a new when . /// Pass a mock in tests. /// -public class AssemblyWriteFileSystem( - IDirectoryInfo checkout, - IDirectoryInfo? output = null, - IFileSystem? inner = null) - : ScopedFileSystem(inner ?? new FileSystem(), BuildOptions(checkout, output, inner)) +public class AssemblyWriteFileSystem(IDirectoryInfo checkout, IDirectoryInfo? output = null, IFileSystem? inner = null) : ScopedFileSystem( + inner ?? new FileSystem(), + BuildOptions(checkout, output, inner) +) { /// /// The per-user application data directory for elastic/docs-builder. @@ -48,10 +47,7 @@ private static string ApplicationDataPath } } - private static ScopedFileSystemOptions BuildOptions( - IDirectoryInfo checkout, - IDirectoryInfo? output, - IFileSystem? inner) + private static ScopedFileSystemOptions BuildOptions(IDirectoryInfo checkout, IDirectoryInfo? output, IFileSystem? inner) { var fs = inner ?? checkout.FileSystem; var checkoutPath = checkout.FullName; @@ -77,8 +73,7 @@ private static ScopedFileSystemOptions BuildOptions( var innerType = fs is ScopedFileSystem sf ? sf.InnerType : fs.GetType(); if (innerType.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase)) { - var innerTemp = fs.Path.GetTempPath().TrimEnd( - System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar); + var innerTemp = fs.Path.GetTempPath().TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar); if (!string.IsNullOrEmpty(innerTemp) && !roots.Contains(innerTemp, StringComparer.OrdinalIgnoreCase)) roots.Add(innerTemp); } @@ -86,7 +81,11 @@ private static ScopedFileSystemOptions BuildOptions( return new ScopedFileSystemOptions([.. roots]) { AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".artifacts" }, - AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".doc.state", ".pagefind-net-frontend-version" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + ".doc.state", + ".pagefind-net-frontend-version" + }, AllowedSpecialFolders = AllowedSpecialFolder.Temp }; } diff --git a/src/Elastic.Documentation/FileSystems/DocumentationWriteFileSystem.cs b/src/Elastic.Documentation/FileSystems/DocumentationWriteFileSystem.cs index 3930fecac7..80086193dc 100644 --- a/src/Elastic.Documentation/FileSystems/DocumentationWriteFileSystem.cs +++ b/src/Elastic.Documentation/FileSystems/DocumentationWriteFileSystem.cs @@ -34,8 +34,8 @@ namespace Elastic.Documentation.FileSystems; public class DocumentationWriteFileSystem( IDirectoryInfo checkout, IDirectoryInfo? output = null, - IFileSystem? inner = null) - : ScopedFileSystem(inner ?? new FileSystem(), BuildOptions(checkout, output, inner)) + IFileSystem? inner = null +) : ScopedFileSystem(inner ?? new FileSystem(), BuildOptions(checkout, output, inner)) { /// @@ -54,10 +54,7 @@ private static string ApplicationDataPath } } - private static ScopedFileSystemOptions BuildOptions( - IDirectoryInfo checkout, - IDirectoryInfo? output, - IFileSystem? inner) + private static ScopedFileSystemOptions BuildOptions(IDirectoryInfo checkout, IDirectoryInfo? output, IFileSystem? inner) { var fs = inner ?? checkout.FileSystem; var checkoutPath = checkout.FullName; @@ -84,8 +81,7 @@ private static ScopedFileSystemOptions BuildOptions( var innerType = fs is ScopedFileSystem sf ? sf.InnerType : fs.GetType(); if (innerType.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase)) { - var innerTemp = fs.Path.GetTempPath().TrimEnd( - System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar); + var innerTemp = fs.Path.GetTempPath().TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar); if (!string.IsNullOrEmpty(innerTemp) && !roots.Contains(innerTemp, StringComparer.OrdinalIgnoreCase)) roots.Add(innerTemp); } @@ -93,7 +89,11 @@ private static ScopedFileSystemOptions BuildOptions( return new ScopedFileSystemOptions([.. roots]) { AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".artifacts" }, - AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".doc.state", ".pagefind-net-frontend-version" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + ".doc.state", + ".pagefind-net-frontend-version" + }, AllowedSpecialFolders = AllowedSpecialFolder.Temp }; } diff --git a/src/Elastic.Documentation/GitCheckoutInformation.cs b/src/Elastic.Documentation/GitCheckoutInformation.cs index 60dcff703c..0aa2f02ba7 100644 --- a/src/Elastic.Documentation/GitCheckoutInformation.cs +++ b/src/Elastic.Documentation/GitCheckoutInformation.cs @@ -44,8 +44,7 @@ public record GitCheckoutInformation /// to a valid GitHub org/repo path. Callers should skip GitHub links when null. /// [JsonIgnore] - public string? GitHubRepository => - Remote is "elastic/docs-builder-unknown" ? null : ExtractGitHubOrgRepo(Remote); + public string? GitHubRepository => Remote is "elastic/docs-builder-unknown" ? null : ExtractGitHubOrgRepo(Remote); /// Extracts a validated org/repo path from a GitHub remote URL, or returns null. /// diff --git a/src/Elastic.Documentation/IEnvironmentVariables.cs b/src/Elastic.Documentation/IEnvironmentVariables.cs index b79944b8bc..568c4a9c9a 100644 --- a/src/Elastic.Documentation/IEnvironmentVariables.cs +++ b/src/Elastic.Documentation/IEnvironmentVariables.cs @@ -39,8 +39,9 @@ public interface IEnvironmentVariables /// When true, MCP Bearer auth middleware validates JWTs. Default false. /// Reads MCP_AUTH_ENABLED (accepts "true", "1"). /// - bool McpAuthEnabled => string.Equals(GetEnvironmentVariable("MCP_AUTH_ENABLED"), "true", StringComparison.OrdinalIgnoreCase) || - string.Equals(GetEnvironmentVariable("MCP_AUTH_ENABLED"), "1", StringComparison.OrdinalIgnoreCase); + bool McpAuthEnabled => + string.Equals(GetEnvironmentVariable("MCP_AUTH_ENABLED"), "true", StringComparison.OrdinalIgnoreCase) || + string.Equals(GetEnvironmentVariable("MCP_AUTH_ENABLED"), "1", StringComparison.OrdinalIgnoreCase); /// /// RSA public key (PEM) for MCP JWT validation. When unset, auth middleware is disabled. @@ -70,5 +71,4 @@ public interface IEnvironmentVariables /// Reads MCP_SERVER_PROFILE. /// string McpServerProfile => GetEnvironmentVariable("MCP_SERVER_PROFILE") ?? "public"; - } diff --git a/src/Elastic.Documentation/Links/CrossLinkValidator.cs b/src/Elastic.Documentation/Links/CrossLinkValidator.cs index 4c61958890..947e4bd108 100644 --- a/src/Elastic.Documentation/Links/CrossLinkValidator.cs +++ b/src/Elastic.Documentation/Links/CrossLinkValidator.cs @@ -15,9 +15,16 @@ public static class CrossLinkValidator /// URI schemes that are excluded from being treated as cross-repository links. /// These are standard web/protocol schemes that should not be processed as crosslinks. /// - private static readonly ImmutableHashSet ExcludedSchemes = - ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase, - "http", "https", "ftp", "file", "tel", "jdbc", "mailto"); + private static readonly ImmutableHashSet ExcludedSchemes = ImmutableHashSet.Create( + StringComparer.OrdinalIgnoreCase, + "http", + "https", + "ftp", + "file", + "tel", + "jdbc", + "mailto" + ); /// /// Validates that a URI string is a valid cross-repository link. @@ -43,7 +50,8 @@ public static bool IsValidCrossLink(string? uriString, out string? errorMessage) if (ExcludedSchemes.Contains(uri.Scheme)) { - errorMessage = $"Cross-link URI '{uriString}' cannot use standard web/protocol schemes ({string.Join(", ", ExcludedSchemes)}). Use cross-repository schemes like 'docs-content://', 'kibana://', etc."; + errorMessage = + $"Cross-link URI '{uriString}' cannot use standard web/protocol schemes ({string.Join(", ", ExcludedSchemes)}). Use cross-repository schemes like 'docs-content://', 'kibana://', etc."; return false; } @@ -57,10 +65,7 @@ public static bool IsValidCrossLink(string? uriString, out string? errorMessage) /// The URI to check /// True if this should be treated as a crosslink public static bool IsCrossLink(Uri? uri) => - uri != null - && !ExcludedSchemes.Contains(uri.Scheme) - && !uri.IsFile - && !string.IsNullOrEmpty(uri.Scheme); + uri != null && !ExcludedSchemes.Contains(uri.Scheme) && !uri.IsFile && !string.IsNullOrEmpty(uri.Scheme); /// /// Gets the list of excluded URI schemes for reference diff --git a/src/Elastic.Documentation/Links/LinkRegistry.cs b/src/Elastic.Documentation/Links/LinkRegistry.cs index bec39f3b6f..68c4de2341 100644 --- a/src/Elastic.Documentation/Links/LinkRegistry.cs +++ b/src/Elastic.Documentation/Links/LinkRegistry.cs @@ -38,19 +38,14 @@ public LinkRegistry WithLinkRegistryEntry(LinkRegistryEntry entry) // onboarding new repository else { - copiedRepositories.Add(repository, new Dictionary - { - { branch, entry } - }); + copiedRepositories.Add(repository, new Dictionary { { branch, entry } }); } return this with { Repositories = copiedRepositories }; } - public static LinkRegistry Deserialize(Stream json) => - JsonSerializer.Deserialize(json, SourceGenerationContext.Default.LinkRegistry)!; + public static LinkRegistry Deserialize(Stream json) => JsonSerializer.Deserialize(json, SourceGenerationContext.Default.LinkRegistry)!; - public static LinkRegistry Deserialize(string json) => - JsonSerializer.Deserialize(json, SourceGenerationContext.Default.LinkRegistry)!; + public static LinkRegistry Deserialize(string json) => JsonSerializer.Deserialize(json, SourceGenerationContext.Default.LinkRegistry)!; public static string Serialize(LinkRegistry registry) => JsonSerializer.Serialize(registry, SourceGenerationContext.Default.LinkRegistry); diff --git a/src/Elastic.Documentation/ReleaseNotes/ChangelogEntry.cs b/src/Elastic.Documentation/ReleaseNotes/ChangelogEntry.cs index 28f8091ad7..5243471fb6 100644 --- a/src/Elastic.Documentation/ReleaseNotes/ChangelogEntry.cs +++ b/src/Elastic.Documentation/ReleaseNotes/ChangelogEntry.cs @@ -50,20 +50,21 @@ public record ChangelogEntry /// Converts this ChangelogEntry to a BundledEntry for embedding in bundles. /// File property is set to null; set it separately using a 'with' expression. /// - public BundledEntry ToBundledEntry() => new() - { - File = null, - Type = Type != ChangelogEntryType.Invalid ? Type : null, - Title = Title, - Products = Products, - Description = Description, - Impact = Impact, - Action = Action, - FeatureId = FeatureId, - Highlight = Highlight, - Subtype = Subtype, - Areas = Areas, - Prs = Prs, - Issues = Issues - }; + public BundledEntry ToBundledEntry() => + new() + { + File = null, + Type = Type != ChangelogEntryType.Invalid ? Type : null, + Title = Title, + Products = Products, + Description = Description, + Impact = Impact, + Action = Action, + FeatureId = FeatureId, + Highlight = Highlight, + Subtype = Subtype, + Areas = Areas, + Prs = Prs, + Issues = Issues + }; } diff --git a/src/Elastic.Documentation/ReleaseNotes/ChangelogTextUtilities.cs b/src/Elastic.Documentation/ReleaseNotes/ChangelogTextUtilities.cs index 0991e13171..2ae4984d48 100644 --- a/src/Elastic.Documentation/ReleaseNotes/ChangelogTextUtilities.cs +++ b/src/Elastic.Documentation/ReleaseNotes/ChangelogTextUtilities.cs @@ -23,9 +23,7 @@ public static string Beautify(string text) return string.Empty; // Capitalize first letter and ensure ends with period - var result = text.Length < 2 - ? char.ToUpperInvariant(text[0]).ToString() - : char.ToUpperInvariant(text[0]) + text[1..]; + var result = text.Length < 2 ? char.ToUpperInvariant(text[0]).ToString() : char.ToUpperInvariant(text[0]) + text[1..]; if (!result.EndsWith('.')) result += "."; return result; @@ -59,9 +57,7 @@ public static string FormatAreaHeader(string area) if (string.IsNullOrWhiteSpace(area)) return string.Empty; - var result = area.Length < 2 - ? char.ToUpperInvariant(area[0]).ToString() - : char.ToUpperInvariant(area[0]) + area[1..]; + var result = area.Length < 2 ? char.ToUpperInvariant(area[0]).ToString() : char.ToUpperInvariant(area[0]) + area[1..]; return result.Replace("-", " "); } @@ -73,9 +69,7 @@ public static string FormatSubtypeHeader(string subtype) if (string.IsNullOrWhiteSpace(subtype)) return string.Empty; - var result = subtype.Length < 2 - ? char.ToUpperInvariant(subtype[0]).ToString() - : char.ToUpperInvariant(subtype[0]) + subtype[1..]; + var result = subtype.Length < 2 ? char.ToUpperInvariant(subtype[0]).ToString() : char.ToUpperInvariant(subtype[0]) + subtype[1..]; return result.Replace("-", " "); } @@ -132,10 +126,7 @@ public static string StripSquareBracketPrefix(string title) if (span.Length > 0 && span[0] == ':') span = span[1..].TrimStart(); - if (removedBracketPrefix && - span.Length >= 2 && - span[0] == '-' && - char.IsWhiteSpace(span[1])) + if (removedBracketPrefix && span.Length >= 2 && span[0] == '-' && char.IsWhiteSpace(span[1])) span = span[2..].TrimStart(); return span.ToString(); @@ -168,15 +159,19 @@ public static bool TitleNeedsDefensiveYamlQuoting(string? title) public static int? ExtractPrNumber(string prUrl, string? defaultOwner = null, string? defaultRepo = null) { // Handle full URL: https://github.com/owner/repo/pull/123 - if (prUrl.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase) || - prUrl.StartsWith("http://github.com/", StringComparison.OrdinalIgnoreCase)) + if ( + prUrl.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase) || + prUrl.StartsWith("http://github.com/", StringComparison.OrdinalIgnoreCase) + ) { var uri = new Uri(prUrl); var segments = uri.Segments; // segments[0] is "/", segments[1] is "owner/", segments[2] is "repo/", segments[3] is "pull/", segments[4] is "123" - if (segments.Length >= 5 && - segments[3].Equals("pull/", StringComparison.OrdinalIgnoreCase) && - int.TryParse(segments[4].TrimEnd('/'), out var prNum)) + if ( + segments.Length >= 5 + && segments[3].Equals("pull/", StringComparison.OrdinalIgnoreCase) + && int.TryParse(segments[4].TrimEnd('/'), out var prNum) + ) return prNum; } @@ -190,8 +185,7 @@ public static bool TitleNeedsDefensiveYamlQuoting(string? title) } // Handle just a PR number when owner/repo are provided - if (int.TryParse(prUrl, out var prNumber) && - !string.IsNullOrWhiteSpace(defaultOwner) && !string.IsNullOrWhiteSpace(defaultRepo)) + if (int.TryParse(prUrl, out var prNumber) && !string.IsNullOrWhiteSpace(defaultOwner) && !string.IsNullOrWhiteSpace(defaultRepo)) return prNumber; return null; @@ -202,14 +196,18 @@ public static bool TitleNeedsDefensiveYamlQuoting(string? title) /// public static int? ExtractIssueNumber(string issueUrl, string? defaultOwner = null, string? defaultRepo = null) { - if (issueUrl.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase) || - issueUrl.StartsWith("http://github.com/", StringComparison.OrdinalIgnoreCase)) + if ( + issueUrl.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase) || + issueUrl.StartsWith("http://github.com/", StringComparison.OrdinalIgnoreCase) + ) { var uri = new Uri(issueUrl); var segments = uri.Segments; - if (segments.Length >= 5 && - segments[3].Equals("issues/", StringComparison.OrdinalIgnoreCase) && - int.TryParse(segments[4].TrimEnd('/'), out var issueNum)) + if ( + segments.Length >= 5 + && segments[3].Equals("issues/", StringComparison.OrdinalIgnoreCase) + && int.TryParse(segments[4].TrimEnd('/'), out var issueNum) + ) return issueNum; } @@ -221,8 +219,11 @@ public static bool TitleNeedsDefensiveYamlQuoting(string? title) return issueNum; } - if (int.TryParse(issueUrl, out var issueNumber) && - !string.IsNullOrWhiteSpace(defaultOwner) && !string.IsNullOrWhiteSpace(defaultRepo)) + if ( + int.TryParse(issueUrl, out var issueNumber) + && !string.IsNullOrWhiteSpace(defaultOwner) + && !string.IsNullOrWhiteSpace(defaultRepo) + ) return issueNumber; return null; @@ -261,8 +262,10 @@ public static bool TryGetGitHubRepo(string reference, string defaultOwner, strin if (trimmed.StartsWith(PrivateReferenceSentinelPrefix, StringComparison.OrdinalIgnoreCase)) return false; - if (trimmed.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase) || - trimmed.StartsWith("http://github.com/", StringComparison.OrdinalIgnoreCase)) + if ( + trimmed.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("http://github.com/", StringComparison.OrdinalIgnoreCase) + ) { try { @@ -275,10 +278,12 @@ public static bool TryGetGitHubRepo(string reference, string defaultOwner, strin return true; } - if (segments.Length == 4 && - (segments[2].Equals("pull", StringComparison.OrdinalIgnoreCase) || - segments[2].Equals("issues", StringComparison.OrdinalIgnoreCase)) && - int.TryParse(segments[3], out _)) + if ( + segments.Length == 4 + && (segments[2].Equals("pull", StringComparison.OrdinalIgnoreCase) || + segments[2].Equals("issues", StringComparison.OrdinalIgnoreCase)) + && int.TryParse(segments[3], out _) + ) { owner = segments[0]; repo = segments[1]; @@ -516,9 +521,7 @@ public static (string? Owner, string Repo) ParseRepository(string repository) return (null, string.Empty); var parts = repository.Split('/'); - return parts.Length >= 2 - ? (parts[0], parts[1]) - : (null, parts[0]); + return parts.Length >= 2 ? (parts[0], parts[1]) : (null, parts[0]); } [GeneratedRegex(@"[^a-z0-9]+", RegexOptions.None)] @@ -534,8 +537,7 @@ public static string GenerateSlug(string title, int maxWords = 6) return "untitled"; // Split on whitespace and take first N words - var words = title - .Split([' ', '\t', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries) + var words = title.Split([' ', '\t', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries) .Take(maxWords) .Select(word => NonAlphanumericRegex().Replace(word.ToLowerInvariant(), string.Empty)) .Where(word => !string.IsNullOrEmpty(word)) diff --git a/src/Elastic.Documentation/ReleaseNotes/ChangelogVersionMatch.cs b/src/Elastic.Documentation/ReleaseNotes/ChangelogVersionMatch.cs index f05fa73531..68574a5237 100644 --- a/src/Elastic.Documentation/ReleaseNotes/ChangelogVersionMatch.cs +++ b/src/Elastic.Documentation/ReleaseNotes/ChangelogVersionMatch.cs @@ -30,7 +30,7 @@ public static bool Matches(string requested, string? target, string? file) return false; var name = Path.GetFileName(file); - return string.Equals(name, value, StringComparison.OrdinalIgnoreCase) - || string.Equals(Path.GetFileNameWithoutExtension(file), value, StringComparison.OrdinalIgnoreCase); + return string.Equals(name, value, StringComparison.OrdinalIgnoreCase) || + string.Equals(Path.GetFileNameWithoutExtension(file), value, StringComparison.OrdinalIgnoreCase); } } diff --git a/src/Elastic.Documentation/ReleaseNotes/LoadedBundle.cs b/src/Elastic.Documentation/ReleaseNotes/LoadedBundle.cs index 05e2604bbf..89df4b0b05 100644 --- a/src/Elastic.Documentation/ReleaseNotes/LoadedBundle.cs +++ b/src/Elastic.Documentation/ReleaseNotes/LoadedBundle.cs @@ -13,21 +13,13 @@ namespace Elastic.Documentation.ReleaseNotes; /// The full parsed bundle data. /// The absolute path to the bundle file. /// Resolved changelog entries (from inline data or file references). -public record LoadedBundle( - string Version, - string Repo, - string Owner, - Bundle Data, - string FilePath, - IReadOnlyList Entries) +public record LoadedBundle(string Version, string Repo, string Owner, Bundle Data, string FilePath, IReadOnlyList Entries) { /// /// Entries grouped by their changelog entry type. /// public IReadOnlyDictionary> EntriesByType => - Entries - .GroupBy(e => e.Type) - .ToDictionary(g => g.Key, g => (IReadOnlyCollection)g.ToList().AsReadOnly()); + Entries.GroupBy(e => e.Type).ToDictionary(g => g.Key, g => (IReadOnlyCollection)g.ToList().AsReadOnly()); /// /// Feature IDs that should be hidden when rendering this bundle. diff --git a/src/Elastic.Documentation/ReleaseNotes/PublishBlockerExtensions.cs b/src/Elastic.Documentation/ReleaseNotes/PublishBlockerExtensions.cs index 43433036e7..b19b19bc63 100644 --- a/src/Elastic.Documentation/ReleaseNotes/PublishBlockerExtensions.cs +++ b/src/Elastic.Documentation/ReleaseNotes/PublishBlockerExtensions.cs @@ -25,8 +25,7 @@ public static bool ShouldBlock(this PublishBlocker blocker, ChangelogEntry entry /// Checks if an entry type matches the blocker's type list. /// public static bool MatchesType(this PublishBlocker blocker, string entryTypeName) => - blocker.Types?.Count > 0 && - blocker.Types.Any(t => t.Equals(entryTypeName, StringComparison.OrdinalIgnoreCase)); + blocker.Types?.Count > 0 && blocker.Types.Any(t => t.Equals(entryTypeName, StringComparison.OrdinalIgnoreCase)); /// /// Gets the preferred area for subsection grouping when publish rules with areas are active. @@ -59,12 +58,9 @@ public static bool MatchesArea(this PublishBlocker blocker, IReadOnlyList entryAreas.All(area => - blocker.Areas.Any(listed => listed.Equals(area, StringComparison.OrdinalIgnoreCase))), - MatchMode.Conjunction => blocker.Areas.All(listed => - entryAreas.Any(e => e.Equals(listed, StringComparison.OrdinalIgnoreCase))), - _ => entryAreas.Any(area => - blocker.Areas.Any(listed => listed.Equals(area, StringComparison.OrdinalIgnoreCase))) + MatchMode.All => entryAreas.All(area => blocker.Areas.Any(listed => listed.Equals(area, StringComparison.OrdinalIgnoreCase))), + MatchMode.Conjunction => blocker.Areas.All(listed => entryAreas.Any(e => e.Equals(listed, StringComparison.OrdinalIgnoreCase))), + _ => entryAreas.Any(area => blocker.Areas.Any(listed => listed.Equals(area, StringComparison.OrdinalIgnoreCase))) }; } diff --git a/src/Elastic.Documentation/Search/ContentHash.cs b/src/Elastic.Documentation/Search/ContentHash.cs index 737ab7999a..16c4886002 100644 --- a/src/Elastic.Documentation/Search/ContentHash.cs +++ b/src/Elastic.Documentation/Search/ContentHash.cs @@ -22,8 +22,7 @@ public static string Create(params string[] components) => /// Collapses all whitespace runs to a single space, trims, then hashes. /// Ensures that whitespace-only changes do not produce a different hash. /// - public static string CreateNormalized(string content) => - Create(WhitespaceRuns().Replace(content.Trim(), " ")); + public static string CreateNormalized(string content) => Create(WhitespaceRuns().Replace(content.Trim(), " ")); [GeneratedRegex(@"\s+")] private static partial Regex WhitespaceRuns(); diff --git a/src/Elastic.Documentation/SymlinkValidator.cs b/src/Elastic.Documentation/SymlinkValidator.cs index 5612c577ab..c596a19eb4 100644 --- a/src/Elastic.Documentation/SymlinkValidator.cs +++ b/src/Elastic.Documentation/SymlinkValidator.cs @@ -24,7 +24,9 @@ public static class SymlinkValidator public static void EnsureNotSymlink(IFileInfo file) { if (file.LinkTarget != null) - throw new SecurityException($"Control file '{file.FullName}' is a symlink, which is not allowed for security reasons. Symlinked control files could be used for path traversal attacks."); + throw new SecurityException( + $"Control file '{file.FullName}' is a symlink, which is not allowed for security reasons. Symlinked control files could be used for path traversal attacks." + ); } /// @@ -50,9 +52,7 @@ public static void EnsureNotSymlink(IFileSystem fileSystem, string filePath) if (file.LinkTarget != null) return "Path must not point to a symlink."; - var cmp = IDirectoryInfoExtensions.IsCaseSensitiveFileSystem - ? StringComparison.Ordinal - : StringComparison.OrdinalIgnoreCase; + var cmp = IDirectoryInfoExtensions.IsCaseSensitiveFileSystem ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; var dir = file.Directory; while (dir != null && !string.Equals(dir.FullName, docRoot.FullName, cmp)) @@ -79,9 +79,7 @@ public static void EnsureNotSymlink(IFileSystem fileSystem, string filePath) if (directory.LinkTarget != null) return "Path must not point to a symlinked directory."; - var cmp = IDirectoryInfoExtensions.IsCaseSensitiveFileSystem - ? StringComparison.Ordinal - : StringComparison.OrdinalIgnoreCase; + var cmp = IDirectoryInfoExtensions.IsCaseSensitiveFileSystem ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; var dir = directory.Parent; while (dir != null && !string.Equals(dir.FullName, docRoot.FullName, cmp)) diff --git a/src/Elastic.Documentation/Text/Utf8TextNormalization.cs b/src/Elastic.Documentation/Text/Utf8TextNormalization.cs index 049a5af622..d139b14e2f 100644 --- a/src/Elastic.Documentation/Text/Utf8TextNormalization.cs +++ b/src/Elastic.Documentation/Text/Utf8TextNormalization.cs @@ -50,8 +50,5 @@ public static class Utf8TextNormalization /// The byte span to check. /// True if the span starts with the UTF-8 BOM sequence, false otherwise. public static bool HasUtf8Bom(ReadOnlySpan bytes) => - bytes.Length >= 3 && - bytes[0] == 0xEF && - bytes[1] == 0xBB && - bytes[2] == 0xBF; + bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF; } diff --git a/src/Elastic.Documentation/Versions/SemVersion.cs b/src/Elastic.Documentation/Versions/SemVersion.cs index 89e853f392..70e410d6ce 100644 --- a/src/Elastic.Documentation/Versions/SemVersion.cs +++ b/src/Elastic.Documentation/Versions/SemVersion.cs @@ -21,10 +21,7 @@ public class ZeroVersion() : SemVersion(0, 0, 0) /// /// A semver2 compatible version. /// -public partial class SemVersion : - IEquatable, - IComparable, - IComparable +public partial class SemVersion : IEquatable, IComparable, IComparable { // https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string private static readonly Regex Regex = MyRegex(); @@ -194,11 +191,7 @@ public static bool TryParse(string input, [NotNullWhen(true)] out SemVersion? ve /// The metadata version part, or null to keep the current value. /// public SemVersion Update(int? major = null, int? minor = null, int? patch = null, string? prerelease = null, string? metadata = null) => - new(major ?? Major, - minor ?? Minor, - patch ?? Patch, - prerelease ?? Prerelease, - metadata ?? Metadata); + new(major ?? Major, minor ?? Minor, patch ?? Patch, prerelease ?? Prerelease, metadata ?? Metadata); /// /// Compares the current version to another version in a natural way (by component/part precedence). @@ -234,11 +227,13 @@ private int CompareByPrecedence(SemVersion? other) /// public bool Equals(SemVersion? other) => - other is not null && ( - ReferenceEquals(this, other) - || (Major == other.Major && Minor == other.Minor && Patch == other.Patch - && Prerelease == other.Prerelease && Metadata == other.Metadata) - ); + other is not null && + (ReferenceEquals(this, other) || + (Major == other.Major + && Minor == other.Minor + && Patch == other.Patch + && Prerelease == other.Prerelease + && Metadata == other.Metadata)); /// public override bool Equals(object? obj) => ReferenceEquals(this, obj) || (obj is SemVersion other && Equals(other)); diff --git a/src/Elastic.Documentation/Versions/VersionSpec.cs b/src/Elastic.Documentation/Versions/VersionSpec.cs index 0fdf7f36ff..544d829720 100644 --- a/src/Elastic.Documentation/Versions/VersionSpec.cs +++ b/src/Elastic.Documentation/Versions/VersionSpec.cs @@ -8,9 +8,7 @@ namespace Elastic.Documentation.Versions; public sealed class AllVersionsSpec : VersionSpec { - private AllVersionsSpec() : base(AllVersions.Instance, null, VersionSpecKind.GreaterThanOrEqual) - { - } + private AllVersionsSpec() : base(AllVersions.Instance, null, VersionSpecKind.GreaterThanOrEqual) { } public static AllVersionsSpec Instance { get; } = new(); @@ -20,8 +18,9 @@ private AllVersionsSpec() : base(AllVersions.Instance, null, VersionSpecKind.Gre public enum VersionSpecKind { GreaterThanOrEqual, // x.x, x.x+, x.x.x, x.x.x+ - Range, // x.x-y.y, x.x.x-y.y.y - Exact // =x.x, =x.x.x + Range, // x.x-y.y, x.x.x-y.y.y + Exact // =x.x, =x.x.x + } /// @@ -82,7 +81,8 @@ public static VersionSpec Range(SemVersion min, SemVersion max, bool showMinPatc /// /// Creates a GreaterThanOrEqual version spec from a SemVersion. /// - public static VersionSpec GreaterThanOrEqual(SemVersion min, bool showPatch = false) => new(min, null, VersionSpecKind.GreaterThanOrEqual, showPatch); + public static VersionSpec GreaterThanOrEqual(SemVersion min, bool showPatch = false) => + new(min, null, VersionSpecKind.GreaterThanOrEqual, showPatch); /// /// Tries to parse a version specification string. @@ -136,8 +136,7 @@ public static bool TryParse(string? input, [NotNullWhen(true)] out VersionSpec? if (maxPart.EndsWith('+')) maxPart = maxPart[..^1]; - if (!TryParseVersion(minPart, out var minVersion) || - !TryParseVersion(maxPart, out var maxVersion)) + if (!TryParseVersion(minPart, out var minVersion) || !TryParseVersion(maxPart, out var maxVersion)) return false; spec = new(minVersion, maxVersion, VersionSpecKind.Range, showMinPatch, showMaxPatch); @@ -225,24 +224,16 @@ private static bool TryParseVersion(string input, [NotNullWhen(true)] out SemVer /// public override string ToString() => Kind switch { - VersionSpecKind.Exact => ShowMinPatch - ? $"={Min.Major}.{Min.Minor}.{Min.Patch}!" - : $"={Min.Major}.{Min.Minor}", + VersionSpecKind.Exact => ShowMinPatch ? $"={Min.Major}.{Min.Minor}.{Min.Patch}!" : $"={Min.Major}.{Min.Minor}", VersionSpecKind.Range => FormatRangeToString(), - VersionSpecKind.GreaterThanOrEqual => ShowMinPatch - ? $"{Min.Major}.{Min.Minor}.{Min.Patch}!+" - : $"{Min.Major}.{Min.Minor}+", + VersionSpecKind.GreaterThanOrEqual => ShowMinPatch ? $"{Min.Major}.{Min.Minor}.{Min.Patch}!+" : $"{Min.Major}.{Min.Minor}+", _ => throw new ArgumentOutOfRangeException(nameof(Kind), Kind, null) }; private string FormatRangeToString() { - var minPart = ShowMinPatch - ? $"{Min.Major}.{Min.Minor}.{Min.Patch}!" - : $"{Min.Major}.{Min.Minor}"; - var maxPart = ShowMaxPatch - ? $"{Max!.Major}.{Max.Minor}.{Max.Patch}!" - : $"{Max!.Major}.{Max.Minor}"; + var minPart = ShowMinPatch ? $"{Min.Major}.{Min.Minor}.{Min.Patch}!" : $"{Min.Major}.{Min.Minor}"; + var maxPart = ShowMaxPatch ? $"{Max!.Major}.{Max.Minor}.{Max.Patch}!" : $"{Max!.Major}.{Max.Minor}"; return $"{minPart}-{maxPart}"; } @@ -273,10 +264,11 @@ public bool Equals(VersionSpec? other) if (ReferenceEquals(this, other)) return true; - return Kind == other.Kind && Min.Equals(other.Min) && - (Max?.Equals(other.Max) ?? (other.Max is null)) && - ShowMinPatch == other.ShowMinPatch && - ShowMaxPatch == other.ShowMaxPatch; + return Kind == other.Kind + && Min.Equals(other.Min) + && (Max?.Equals(other.Max) ?? (other.Max is null)) + && ShowMinPatch == other.ShowMinPatch + && ShowMaxPatch == other.ShowMaxPatch; } public override bool Equals(object? obj) => obj is VersionSpec other && Equals(other); @@ -292,17 +284,13 @@ public bool Equals(VersionSpec? other) public static bool operator !=(VersionSpec? left, VersionSpec? right) => !(left == right); - public static bool operator <(VersionSpec? left, VersionSpec? right) => - left is null ? right is not null : left.CompareTo(right) < 0; + public static bool operator <(VersionSpec? left, VersionSpec? right) => left is null ? right is not null : left.CompareTo(right) < 0; - public static bool operator <=(VersionSpec? left, VersionSpec? right) => - left is null || left.CompareTo(right) <= 0; + public static bool operator <=(VersionSpec? left, VersionSpec? right) => left is null || left.CompareTo(right) <= 0; - public static bool operator >(VersionSpec? left, VersionSpec? right) => - left is not null && left.CompareTo(right) > 0; + public static bool operator >(VersionSpec? left, VersionSpec? right) => left is not null && left.CompareTo(right) > 0; - public static bool operator >=(VersionSpec? left, VersionSpec? right) => - left is null ? right is null : left.CompareTo(right) >= 0; + public static bool operator >=(VersionSpec? left, VersionSpec? right) => left is null ? right is null : left.CompareTo(right) >= 0; /// /// Explicit conversion from string to VersionSpec diff --git a/src/Elastic.Markdown/DescriptionGenerator.cs b/src/Elastic.Markdown/DescriptionGenerator.cs index 30f74bee17..ad7944e37f 100644 --- a/src/Elastic.Markdown/DescriptionGenerator.cs +++ b/src/Elastic.Markdown/DescriptionGenerator.cs @@ -14,7 +14,6 @@ public interface IDescriptionGenerator string GenerateDescription(MarkdownDocument document); } - public class DescriptionGenerator : IDescriptionGenerator { private const int MaxLength = 150; diff --git a/src/Elastic.Markdown/Diagnostics/ProcessorDiagnosticExtensions.cs b/src/Elastic.Markdown/Diagnostics/ProcessorDiagnosticExtensions.cs index 9a573ae4a0..09da3dc856 100644 --- a/src/Elastic.Markdown/Diagnostics/ProcessorDiagnosticExtensions.cs +++ b/src/Elastic.Markdown/Diagnostics/ProcessorDiagnosticExtensions.cs @@ -12,18 +12,31 @@ namespace Elastic.Markdown.Diagnostics; public static class ProcessorDiagnosticExtensions { - private static string CreateExceptionMessage(string message, Exception? e) => message + (e != null ? Environment.NewLine + e : string.Empty); + private static string CreateExceptionMessage(string message, Exception? e) => + message + (e != null ? Environment.NewLine + e : string.Empty); public static void EmitError(this BlockProcessor processor, string message, int? line = null, int? column = null, int? length = null) => processor.Emit(Severity.Error, message, line, column, length); - public static void EmitWarning(this BlockProcessor processor, string message, int? line = null, int? column = null, int? length = null) => - processor.Emit(Severity.Warning, message, line, column, length); + public static void EmitWarning( + this BlockProcessor processor, + string message, + int? line = null, + int? column = null, + int? length = null + ) => processor.Emit(Severity.Warning, message, line, column, length); public static void EmitHint(this BlockProcessor processor, string message, int? line = null, int? column = null, int? length = null) => processor.Emit(Severity.Hint, message, line, column, length); - public static void Emit(this BlockProcessor processor, Severity severity, string message, int? line = null, int? column = null, int? length = null) + public static void Emit( + this BlockProcessor processor, + Severity severity, + string message, + int? line = null, + int? column = null, + int? length = null + ) { var context = processor.GetContext(); if (context.SkipValidation) @@ -41,7 +54,6 @@ public static void Emit(this BlockProcessor processor, Severity severity, string context.Build.Collector.Write(d); } - public static void EmitError(this InlineProcessor processor, int line, int column, int length, string message) => processor.Emit(Severity.Error, line, column, length, message); @@ -100,7 +112,8 @@ public static void EmitWarning(this ParserContext context, int line, int column, context.Build.Collector.Write(d); } - public static void EmitError(this IBlockExtension block, string message, Exception? e = null) => Emit(block, Severity.Error, message, e); + public static void EmitError(this IBlockExtension block, string message, Exception? e = null) => + Emit(block, Severity.Error, message, e); public static void EmitWarning(this IBlockExtension block, string message) => Emit(block, Severity.Warning, message); @@ -123,8 +136,14 @@ public static void Emit(this IBlockExtension block, Severity severity, string me block.Build.Collector.Write(d); } - - public static void Emit(this InlineProcessor processor, Severity severity, Inline inline, int length, string message, Exception? e = null) + public static void Emit( + this InlineProcessor processor, + Severity severity, + Inline inline, + int length, + string message, + Exception? e = null + ) { var line = inline.Line + 1; var column = inline.Column; diff --git a/src/Elastic.Markdown/DocumentationGenerator.cs b/src/Elastic.Markdown/DocumentationGenerator.cs index 9853605938..a9358efe35 100644 --- a/src/Elastic.Markdown/DocumentationGenerator.cs +++ b/src/Elastic.Markdown/DocumentationGenerator.cs @@ -78,24 +78,43 @@ public DocumentationGenerator( Context = docSet.Context; // Use the provided inferrer or create a default one - _documentInferrer = documentInferrer ?? new DocumentInferrerService( - DocumentationSet.Context.ProductsConfiguration, - DocumentationSet.Context.VersionsConfiguration, - DocumentationSet.Context.LegacyUrlMappings, - DocumentationSet.Configuration, - DocumentationSet.Context.Git - ); - - HtmlWriter = new HtmlWriter(DocumentationSet, _writeFileSystem, new DescriptionGenerator(), pageViewFactory, positionalNavigation, navigationHtmlWriter, legacyUrlMapper, _documentInferrer); - _documentationFileExporter = - docSet.Context.AvailableExporters.Contains(Exporter.Html) - ? docSet.EnabledExtensions.FirstOrDefault(e => e.FileExporter != null)?.FileExporter - ?? new DocumentationFileExporter(docSet.Context.ReadFileSystem, _writeFileSystem) - : new NoopDocumentationFileExporter(); + _documentInferrer = + documentInferrer ?? + new DocumentInferrerService( + DocumentationSet.Context.ProductsConfiguration, + DocumentationSet.Context.VersionsConfiguration, + DocumentationSet.Context.LegacyUrlMappings, + DocumentationSet.Configuration, + DocumentationSet.Context.Git + ); + + HtmlWriter = + new HtmlWriter( + DocumentationSet, + _writeFileSystem, + new DescriptionGenerator(), + pageViewFactory, + positionalNavigation, + navigationHtmlWriter, + legacyUrlMapper, + _documentInferrer + ); + _documentationFileExporter = docSet.Context.AvailableExporters.Contains(Exporter.Html) + ? docSet.EnabledExtensions.FirstOrDefault(e => e.FileExporter != null)?.FileExporter ?? + new DocumentationFileExporter(docSet.Context.ReadFileSystem, _writeFileSystem) + : new NoopDocumentationFileExporter(); _logger.LogInformation("Created documentation set for: {DocumentationSetName}", DocumentationSet.Name); - _logger.LogInformation("Source directory: {SourcePath} Exists: {SourcePathExists}", docSet.SourceDirectory, docSet.SourceDirectory.Exists); - _logger.LogInformation("Output directory: {OutputPath} Exists: {OutputPathExists}", docSet.OutputDirectory, docSet.OutputDirectory.Exists); + _logger.LogInformation( + "Source directory: {SourcePath} Exists: {SourcePathExists}", + docSet.SourceDirectory, + docSet.SourceDirectory.Exists + ); + _logger.LogInformation( + "Output directory: {OutputPath} Exists: {OutputPathExists}", + docSet.OutputDirectory, + docSet.OutputDirectory.Exists + ); } private INavigationTraversable PositionalNavigation { get; } @@ -125,9 +144,11 @@ public async Task GenerateAll(Cancel ctx) var generationState = !generateState ? null : GetPreviousGenerationState(); // clear the output directory if force is true but never for assembler builds since these build multiple times to the output. - if (Context is { BuildType: not BuildType.Assembler, Force: true } + if ( + Context is { BuildType: not BuildType.Assembler, Force: true } // clear the output directory if force is false but generation state is null, except for assembler builds. - || (Context is { BuildType: not BuildType.Assembler, Force: false } && generationState == null)) + || (Context is { BuildType: not BuildType.Assembler, Force: false } && generationState == null) + ) { _logger.LogInformation($"Clearing output directory"); DocumentationSet.ClearOutputDirectory(); @@ -163,41 +184,56 @@ public async Task GenerateAll(Cancel ctx) var writeToDisk = Context.AvailableExporters.Contains(Exporter.LinkMetadata); var linkReference = await GenerateLinkReference(writeToDisk, ctx); - return result with - { - Redirects = linkReference.Redirects ?? [] - }; + return result with { Redirects = linkReference.Redirects ?? [] }; } - private async Task ProcessDocumentationFiles(HashSet offendingFiles, DateTimeOffset outputSeenChanges, CompilationMode mode, Cancel ctx) + private async Task ProcessDocumentationFiles( + HashSet offendingFiles, + DateTimeOffset outputSeenChanges, + CompilationMode mode, + Cancel ctx + ) { var processedFileCount = 0; var exceptionCount = 0; var totalFileCount = DocumentationSet.Files.Count; - await Parallel.ForEachAsync(DocumentationSet.Files, ctx, async (file, token) => - { - var processedFiles = Interlocked.Increment(ref processedFileCount); - var (fp, doc) = file; - try - { - await ProcessFile(offendingFiles, doc, outputSeenChanges, mode, token); - } - catch (Exception e) + await Parallel.ForEachAsync( + DocumentationSet.Files, + ctx, + async (file, token) => { - var currentCount = Interlocked.Increment(ref exceptionCount); - // this is not the main error logging mechanism - // if we hit this from too many files fail hard - if (currentCount <= 25) - Context.Collector.EmitError(fp.RelativePath, "Uncaught exception while processing file", e); - else - throw; - } - - if (processedFiles % 100 == 0) - _logger.LogInformation(" {Name} -> Processed {ProcessedFiles}/{TotalFileCount} files", Context.Git.RepositoryName, processedFiles, totalFileCount); - }); - _logger.LogInformation(" {Name} -> Processed {ProcessedFileCount}/{TotalFileCount} files", Context.Git.RepositoryName, processedFileCount, totalFileCount); + var processedFiles = Interlocked.Increment(ref processedFileCount); + var (fp, doc) = file; + try + { + await ProcessFile(offendingFiles, doc, outputSeenChanges, mode, token); + } + catch (Exception e) + { + var currentCount = Interlocked.Increment(ref exceptionCount); + // this is not the main error logging mechanism + // if we hit this from too many files fail hard + if (currentCount <= 25) + Context.Collector.EmitError(fp.RelativePath, "Uncaught exception while processing file", e); + else + throw; + } + if (processedFiles % 100 == 0) + _logger.LogInformation( + " {Name} -> Processed {ProcessedFiles}/{TotalFileCount} files", + Context.Git.RepositoryName, + processedFiles, + totalFileCount + ); + } + ); + _logger.LogInformation( + " {Name} -> Processed {ProcessedFileCount}/{TotalFileCount} files", + Context.Git.RepositoryName, + processedFileCount, + totalFileCount + ); } private void CopyBrandingResources() @@ -226,8 +262,10 @@ private void CopyBrandingResources() if (!seen.Add(source.Name)) { - Context.Collector.EmitError(Context.ConfigurationPath.FullName, - $"Branding image '{imagePath}' has the same filename as another branding image — use unique filenames to avoid overwriting."); + Context.Collector.EmitError( + Context.ConfigurationPath.FullName, + $"Branding image '{imagePath}' has the same filename as another branding image — use unique filenames to avoid overwriting." + ); continue; } @@ -248,14 +286,14 @@ private void HintUnusedSubstitutionKeys() var definedKeys = new HashSet(Context.Configuration.Substitutions.Keys.ToArray()); var inUse = new HashSet(Context.Collector.InUseSubstitutionKeys.Keys); var keysNotInUse = definedKeys.Except(inUse) - // versions keys are injected - .Where(key => !key.StartsWith("version.")) - // product keys are injected - .Where(key => !key.StartsWith("product.")) - .Where(key => !key.StartsWith('.')) - // reserving context namespace - .Where(key => !key.StartsWith("context.")) - .ToArray(); + // versions keys are injected + .Where(key => !key.StartsWith("version.")) + // product keys are injected + .Where(key => !key.StartsWith("product.")) + .Where(key => !key.StartsWith('.')) + // reserving context namespace + .Where(key => !key.StartsWith("context.")) + .ToArray(); // If we have less than 20 unused keys, emit them separately, // Otherwise emit one hint with all of them for brevity @@ -282,8 +320,9 @@ private async Task ExtractEmbeddedStaticResources(Cancel ctx) _logger.LogInformation($"Copying static files to output directory"); var assembly = typeof(EmbeddedOrPhysicalFileProvider).Assembly; - foreach (var a in assembly.GetManifestResourceNames() - .Where(r => r.StartsWith("Elastic.Documentation.Site._static.", StringComparison.Ordinal))) + foreach (var a in assembly.GetManifestResourceNames().Where( + r => r.StartsWith("Elastic.Documentation.Site._static.", StringComparison.Ordinal) + )) { await using var resourceStream = assembly.GetManifestResourceStream(a); if (resourceStream == null) @@ -305,27 +344,32 @@ private async Task ExtractEmbeddedStaticResources(Cancel ctx) [GeneratedRegex(@"^[a-z0-9_][a-z0-9_\-\s\.+]*?\.([a-z]+)$")] private static partial Regex FileNameRegex(); - public static bool IsValidFileName(string strToCheck) => - strToCheck switch - { - //prior art - _ when strToCheck.StartsWith("release-notes/elastic-agent/_snippets/") => true, - _ when strToCheck.StartsWith("reference/query-languages/esql/_snippets/") => true, - _ when strToCheck.EndsWith(".svg") => true, - _ when strToCheck.EndsWith(".gif") => true, - _ when strToCheck.EndsWith(".png") => true, - _ when strToCheck.EndsWith(".png") => true, - "reference/security/prebuilt-rules/audit_policies/windows/README.md" => true, - "audit_policies/windows/README.md" => true, - "extend/integrations/developer-workflow-fleet-UI.md" => true, - "extend/developer-workflow-fleet-UI.md" => true, - "reference/elasticsearch/clients/ruby/Helpers.md" => true, - "reference/Helpers.md" => true, - "explore-analyze/ai-features/llm-guides/connect-to-vLLM.md" => true, - _ => FilePathRegex().IsMatch(strToCheck) && FileNameRegex().IsMatch(Path.GetFileName(strToCheck)) - }; - - private async Task ProcessFile(HashSet offendingFiles, DocumentationFile file, DateTimeOffset outputSeenChanges, CompilationMode mode, Cancel ctx) + public static bool IsValidFileName(string strToCheck) => strToCheck switch + { + //prior art + _ when strToCheck.StartsWith("release-notes/elastic-agent/_snippets/") => true, + _ when strToCheck.StartsWith("reference/query-languages/esql/_snippets/") => true, + _ when strToCheck.EndsWith(".svg") => true, + _ when strToCheck.EndsWith(".gif") => true, + _ when strToCheck.EndsWith(".png") => true, + _ when strToCheck.EndsWith(".png") => true, + "reference/security/prebuilt-rules/audit_policies/windows/README.md" => true, + "audit_policies/windows/README.md" => true, + "extend/integrations/developer-workflow-fleet-UI.md" => true, + "extend/developer-workflow-fleet-UI.md" => true, + "reference/elasticsearch/clients/ruby/Helpers.md" => true, + "reference/Helpers.md" => true, + "explore-analyze/ai-features/llm-guides/connect-to-vLLM.md" => true, + _ => FilePathRegex().IsMatch(strToCheck) && FileNameRegex().IsMatch(Path.GetFileName(strToCheck)) + }; + + private async Task ProcessFile( + HashSet offendingFiles, + DocumentationFile file, + DateTimeOffset outputSeenChanges, + CompilationMode mode, + Cancel ctx + ) { // Full builds run HintUnusedSubstitutionKeys(), which needs substitution usage from every file. // CI forces Full mode while still supplying outputSeenChanges from state; skipping unchanged files would miss keys and produce false hints. @@ -362,7 +406,10 @@ private async Task ProcessFile(HashSet offendingFiles, DocumentationFile var relative = Path.GetRelativePath(Context.OutputDirectory.FullName, outputFile.FullName); if (!IsValidFileName(relative)) { - Context.Collector.EmitError(file.SourceFile.FullName, $"File name {relative} is not valid needs to be lowercase and contain only alphanumeric characters, spaces, dashes, dots, underscores, and plus signs"); + Context.Collector.EmitError( + file.SourceFile.FullName, + $"File name {relative} is not valid needs to be lowercase and contain only alphanumeric characters, spaces, dashes, dots, underscores, and plus signs" + ); return; } @@ -379,20 +426,25 @@ private async Task ProcessFile(HashSet offendingFiles, DocumentationFile { foreach (var exporter in _markdownExporters) { - var document = context.MarkdownDocument ??= await markdown.ParseFullAsync(DocumentationSet.TryFindDocumentByRelativePath, ctx); + var document = + context.MarkdownDocument ??= await markdown.ParseFullAsync(DocumentationSet.TryFindDocumentByRelativePath, ctx); var navigationItem = PositionalNavigation.GetNavigationFor(markdown); - _ = await exporter.ExportAsync(new MarkdownExportFileContext - { - BuildContext = Context, - Resolvers = DocumentationSet.MarkdownParser.Resolvers, - Document = document, - SourceFile = markdown, - DefaultOutputFile = outputFile, - DocumentationSet = DocumentationSet, - PositionaNavigation = PositionalNavigation, - NavigationItem = navigationItem, - InferenceService = _documentInferrer - }, ctx); + _ = + await exporter.ExportAsync( + new MarkdownExportFileContext + { + BuildContext = Context, + Resolvers = DocumentationSet.MarkdownParser.Resolvers, + Document = document, + SourceFile = markdown, + DefaultOutputFile = outputFile, + DocumentationSet = DocumentationSet, + PositionaNavigation = PositionalNavigation, + NavigationItem = navigationItem, + InferenceService = _documentInferrer + }, + ctx + ); } } } @@ -409,10 +461,18 @@ private async Task ProcessFile(HashSet offendingFiles, DocumentationFile : outputFile; } - private enum CompilationMode { Full, Incremental, Skip } + private enum CompilationMode + { + Full, + Incremental, + Skip + } - private CompilationMode GetCompilationMode(GenerationState? generationState, out HashSet offendingFiles, - out DateTimeOffset outputSeenChanges) + private CompilationMode GetCompilationMode( + GenerationState? generationState, + out HashSet offendingFiles, + out DateTimeOffset outputSeenChanges + ) { offendingFiles = [.. generationState?.InvalidFiles ?? []]; outputSeenChanges = generationState?.LastSeenChanges ?? DateTimeOffset.MinValue; @@ -430,8 +490,11 @@ private CompilationMode GetCompilationMode(GenerationState? generationState, out if (Context.Git != generationState.Git) { - _logger.LogInformation("Full compilation: current git context: {CurrentGitContext} differs from previous git context: {PreviousGitContext}", - Context.Git, generationState.Git); + _logger.LogInformation( + "Full compilation: current git context: {CurrentGitContext} differs from previous git context: {PreviousGitContext}", + Context.Git, + generationState.Git + ); return CompilationMode.Full; } @@ -449,8 +512,8 @@ private CompilationMode GetCompilationMode(GenerationState? generationState, out else if (DocumentationSet.LastWrite <= outputSeenChanges) { _logger.LogInformation( - "No compilation: no changes since last observed: {LastSeenChanges}. " + - "Pass --force to force a full regeneration", generationState.LastSeenChanges + "No compilation: no changes since last observed: {LastSeenChanges}. " + "Pass --force to force a full regeneration", + generationState.LastSeenChanges ); return CompilationMode.Skip; } @@ -475,7 +538,11 @@ private async Task GenerateLinkReference(bool writeToDisk, Canc private async Task GenerateDocumentationState(Cancel ctx) { var stateFile = DocumentationSet.OutputStateFile; - _logger.LogInformation("Writing documentation state {LastWrite} to {StateFileName}", DocumentationSet.LastWrite, stateFile.FullName); + _logger.LogInformation( + "Writing documentation state {LastWrite} to {StateFileName}", + DocumentationSet.LastWrite, + stateFile.FullName + ); var badFiles = Context.Collector.OffendingFiles.ToArray(); var state = new GenerationState { @@ -523,5 +590,4 @@ private bool IsApiMarkdownFile(string relativePath) return false; } - } diff --git a/src/Elastic.Markdown/Exporters/ConfigurationExporter.cs b/src/Elastic.Markdown/Exporters/ConfigurationExporter.cs index fed21575a6..755a831fa5 100644 --- a/src/Elastic.Markdown/Exporters/ConfigurationExporter.cs +++ b/src/Elastic.Markdown/Exporters/ConfigurationExporter.cs @@ -13,7 +13,6 @@ public class ConfigurationExporter( ILoggerFactory logFactory, ConfigurationFileProvider configurationFileProvider, IDocumentationContext context - ) : IMarkdownExporter { private readonly ILogger _logger = logFactory.CreateLogger(); diff --git a/src/Elastic.Markdown/Exporters/DocumentationFileExporter.cs b/src/Elastic.Markdown/Exporters/DocumentationFileExporter.cs index 50c4815d1b..6ad6a74d60 100644 --- a/src/Elastic.Markdown/Exporters/DocumentationFileExporter.cs +++ b/src/Elastic.Markdown/Exporters/DocumentationFileExporter.cs @@ -58,8 +58,10 @@ public async Task CopyEmbeddedResource(IFileInfo outputFile, Stream resourceStre } } -public class DocumentationFileExporter(IFileSystem readFileSystem, IFileSystem writeFileSystem) - : DocumentationFileExporterBase(readFileSystem, writeFileSystem) +public class DocumentationFileExporter(IFileSystem readFileSystem, IFileSystem writeFileSystem) : DocumentationFileExporterBase( + readFileSystem, + writeFileSystem +) { public override string Name => nameof(DocumentationFileExporter); diff --git a/src/Elastic.Markdown/Exporters/Elasticsearch/ContentDateEnrichment.cs b/src/Elastic.Markdown/Exporters/Elasticsearch/ContentDateEnrichment.cs index ed849346da..72430bb296 100644 --- a/src/Elastic.Markdown/Exporters/Elasticsearch/ContentDateEnrichment.cs +++ b/src/Elastic.Markdown/Exporters/Elasticsearch/ContentDateEnrichment.cs @@ -21,7 +21,8 @@ public class ContentDateEnrichment( ElasticsearchOperations operations, ILogger logger, string buildType, - string environment) + string environment +) { private readonly string _lookupAlias = $"docs-{buildType}-content-dates-{environment}"; @@ -88,15 +89,11 @@ public async Task SyncLookupIndexAsync(string lexicalAlias, Cancel ct) { ["bool"] = new JsonObject { - ["must_not"] = new JsonArray( - new JsonObject + ["must_not"] = + new JsonArray(new JsonObject { - ["range"] = new JsonObject - { - ["content_last_updated"] = new JsonObject { ["gt"] = "1970-01-01T00:00:00Z" } - } - } - ) + ["range"] = new JsonObject { ["content_last_updated"] = new JsonObject { ["gt"] = "1970-01-01T00:00:00Z" } } + }) } } }.ToJsonString(); @@ -110,23 +107,22 @@ public async Task ResolveContentDatesAsync(string indexAlias, Cancel ct, TimeSpa logger.LogInformation("Content date resolution complete for {Index}", indexAlias); } - private string GenerateStagingName() => - $"{_lookupAlias}-{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid().ToString("N")[..8]}"; + private string GenerateStagingName() => $"{_lookupAlias}-{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid().ToString("N")[..8]}"; private async Task ResolveBackingIndexAsync(Cancel ct) { - var response = await operations.WithRetryAsync( - () => transport.GetAsync($"/_alias/{_lookupAlias}", ct), - $"GET /_alias/{_lookupAlias}", - ct - ); + var response = + await operations.WithRetryAsync( + () => transport.GetAsync($"/_alias/{_lookupAlias}", ct), + $"GET /_alias/{_lookupAlias}", + ct + ); if (response.ApiCallDetails.HttpStatusCode == 404) return null; if (!response.ApiCallDetails.HasSuccessfulStatusCode) - throw new InvalidOperationException( - $"Failed to resolve alias {_lookupAlias}: {response.ApiCallDetails.DebugInformation}"); + throw new InvalidOperationException($"Failed to resolve alias {_lookupAlias}: {response.ApiCallDetails.DebugInformation}"); var json = JsonNode.Parse(response.Body); var indices = json?.AsObject().Select(kv => kv.Key).ToList() ?? []; @@ -135,8 +131,10 @@ private string GenerateStagingName() => { 0 => null, 1 => indices[0], - _ => throw new InvalidOperationException( - $"Alias {_lookupAlias} points to multiple indices ({string.Join(", ", indices)}); expected exactly one") + _ => + throw new InvalidOperationException( + $"Alias {_lookupAlias} points to multiple indices ({string.Join(", ", indices)}); expected exactly one" + ) }; } @@ -171,14 +169,16 @@ private async Task CreateLookupIndexAsync(string indexName, Cancel ct) } }; - var response = await operations.WithRetryAsync( - () => transport.PutAsync(indexName, PostData.String(mapping.ToJsonString()), ct), - $"PUT {indexName}", - ct - ); + var response = + await operations.WithRetryAsync( + () => transport.PutAsync(indexName, PostData.String(mapping.ToJsonString()), ct), + $"PUT {indexName}", + ct + ); if (!response.ApiCallDetails.HasSuccessfulStatusCode) throw new InvalidOperationException( - $"Failed to create content date lookup index {indexName}: {response.ApiCallDetails.DebugInformation}"); + $"Failed to create content date lookup index {indexName}: {response.ApiCallDetails.DebugInformation}" + ); logger.LogInformation("Created content date lookup index {Index}", indexName); } @@ -188,34 +188,34 @@ private async Task SwapAliasAsync(string? oldIndex, string newIndex, Cancel ct) var addAction = new JsonObject { ["add"] = new JsonObject { ["index"] = newIndex, ["alias"] = _lookupAlias } }; var actions = oldIndex != null - ? new JsonArray( - new JsonObject { ["remove"] = new JsonObject { ["index"] = oldIndex, ["alias"] = _lookupAlias } }, - addAction - ) + ? new JsonArray(new JsonObject { ["remove"] = new JsonObject { ["index"] = oldIndex, ["alias"] = _lookupAlias } }, addAction) : [(JsonNode)addAction]; var body = new JsonObject { ["actions"] = actions }; - var response = await operations.WithRetryAsync( - () => transport.PostAsync("/_aliases", PostData.String(body.ToJsonString()), ct), - "POST /_aliases", - ct - ); + var response = + await operations.WithRetryAsync( + () => transport.PostAsync("/_aliases", PostData.String(body.ToJsonString()), ct), + "POST /_aliases", + ct + ); if (!response.ApiCallDetails.HasSuccessfulStatusCode) throw new InvalidOperationException( - $"Failed to swap alias {_lookupAlias} to {newIndex}: {response.ApiCallDetails.DebugInformation}"); + $"Failed to swap alias {_lookupAlias} to {newIndex}: {response.ApiCallDetails.DebugInformation}" + ); logger.LogInformation("Swapped alias {Alias} from {OldIndex} to {NewIndex}", _lookupAlias, oldIndex ?? "(none)", newIndex); } private async Task DeleteIndexAsync(string indexName, Cancel ct) { - var response = await operations.WithRetryAsync( - () => transport.DeleteAsync(indexName, new DefaultRequestParameters(), PostData.Empty, ct), - $"DELETE {indexName}", - ct - ); + var response = + await operations.WithRetryAsync( + () => transport.DeleteAsync(indexName, new DefaultRequestParameters(), PostData.Empty, ct), + $"DELETE {indexName}", + ct + ); if (!response.ApiCallDetails.HasSuccessfulStatusCode) logger.LogWarning("Failed to delete old lookup index {Index}: {Info}", indexName, response.ApiCallDetails.DebugInformation); @@ -225,15 +225,17 @@ private async Task DeleteIndexAsync(string indexName, Cancel ct) private async Task PutEnrichPolicyAsync(Cancel ct) { - var response = await operations.WithRetryAsync( - () => transport.PutAsync( - $"/_enrich/policy/{PolicyName}", - PostData.String(BuildPolicyBody().ToJsonString()), + var response = + await operations.WithRetryAsync( + () => + transport.PutAsync( + $"/_enrich/policy/{PolicyName}", + PostData.String(BuildPolicyBody().ToJsonString()), + ct + ), + $"PUT _enrich/policy/{PolicyName}", ct - ), - $"PUT _enrich/policy/{PolicyName}", - ct - ); + ); if (response.ApiCallDetails.HasSuccessfulStatusCode) { @@ -242,17 +244,14 @@ private async Task PutEnrichPolicyAsync(Cancel ct) } // Same-hash policy already exists — the definition is identical, safe to reuse - var errorType = response.Body != null - ? JsonNode.Parse(response.Body)?["error"]?["type"]?.GetValue() - : null; + var errorType = response.Body != null ? JsonNode.Parse(response.Body)?["error"]?["type"]?.GetValue() : null; if (errorType == "resource_already_exists_exception") { logger.LogInformation("Enrich policy {Policy} already exists, continuing", PolicyName); return; } - throw new InvalidOperationException( - $"Failed to create enrich policy {PolicyName}: {response.ApiCallDetails.DebugInformation}"); + throw new InvalidOperationException($"Failed to create enrich policy {PolicyName}: {response.ApiCallDetails.DebugInformation}"); } private string ComputePolicyHash() @@ -262,23 +261,21 @@ private string ComputePolicyHash() return Convert.ToHexString(hash)[..8].ToLowerInvariant(); } - private JsonObject BuildPolicyBody() => new() - { - ["match"] = new JsonObject + private JsonObject BuildPolicyBody() => + new() { - ["indices"] = _lookupAlias, - ["match_field"] = "url", - ["enrich_fields"] = new JsonArray("content_hash", "content_last_updated") - } - }; + ["match"] = new JsonObject + { + ["indices"] = _lookupAlias, + ["match_field"] = "url", + ["enrich_fields"] = new JsonArray("content_hash", "content_last_updated") + } + }; private async Task CleanupOldPoliciesAsync(Cancel ct) { - var response = await operations.WithRetryAsync( - () => transport.GetAsync("/_enrich/policy", ct), - "GET /_enrich/policy", - ct - ); + var response = + await operations.WithRetryAsync(() => transport.GetAsync("/_enrich/policy", ct), "GET /_enrich/policy", ct); if (!response.ApiCallDetails.HasSuccessfulStatusCode) { @@ -295,30 +292,38 @@ private async Task CleanupOldPoliciesAsync(Cancel ct) if (name == null || name == PolicyName || !name.StartsWith(PolicyBaseName, StringComparison.Ordinal)) continue; - var deleteResponse = await operations.WithRetryAsync( - () => transport.DeleteAsync($"/_enrich/policy/{name}", new DefaultRequestParameters(), PostData.Empty, ct), - $"DELETE _enrich/policy/{name}", - ct - ); + var deleteResponse = + await operations.WithRetryAsync( + () => + transport.DeleteAsync($"/_enrich/policy/{name}", new DefaultRequestParameters(), PostData.Empty, ct), + $"DELETE _enrich/policy/{name}", + ct + ); if (deleteResponse.ApiCallDetails.HasSuccessfulStatusCode) logger.LogInformation("Deleted old enrich policy {Policy}", name); else - logger.LogWarning("Failed to delete old enrich policy {Policy}: {Info}", name, deleteResponse.ApiCallDetails.DebugInformation); + logger.LogWarning( + "Failed to delete old enrich policy {Policy}: {Info}", + name, + deleteResponse.ApiCallDetails.DebugInformation + ); } } private async Task ExecutePolicyAsync(Cancel ct) { - var response = await operations.WithRetryAsync( - () => transport.PostAsync($"/_enrich/policy/{PolicyName}/_execute", PostData.Empty, ct), - $"POST _enrich/policy/{PolicyName}/_execute", - ct - ); + var response = + await operations.WithRetryAsync( + () => transport.PostAsync($"/_enrich/policy/{PolicyName}/_execute", PostData.Empty, ct), + $"POST _enrich/policy/{PolicyName}/_execute", + ct + ); if (!response.ApiCallDetails.HasSuccessfulStatusCode) throw new InvalidOperationException( - $"Failed to execute enrich policy {PolicyName}: {response.ApiCallDetails.DebugInformation}"); + $"Failed to execute enrich policy {PolicyName}: {response.ApiCallDetails.DebugInformation}" + ); logger.LogInformation("Executed enrich policy {Policy}", PolicyName); } @@ -328,67 +333,67 @@ private async Task PutPipelineAsync(Cancel ct) var pipeline = new JsonObject { ["description"] = "Resolves content_last_updated via enrich policy lookup on content_hash", - ["processors"] = new JsonArray( - new JsonObject - { - ["set"] = new JsonObject + ["processors"] = + new JsonArray( + new JsonObject { - ["field"] = "content_last_updated", - ["value"] = "{{{_ingest.timestamp}}}" - } - }, - new JsonObject - { - ["enrich"] = new JsonObject + ["set"] = new JsonObject { ["field"] = "content_last_updated", ["value"] = "{{{_ingest.timestamp}}}" } + }, + new JsonObject { - ["policy_name"] = PolicyName, - ["field"] = "url", - ["target_field"] = "_content_date_lookup", - ["max_matches"] = 1, - ["ignore_missing"] = true - } - }, - new JsonObject - { - ["script"] = new JsonObject + ["enrich"] = new JsonObject + { + ["policy_name"] = PolicyName, + ["field"] = "url", + ["target_field"] = "_content_date_lookup", + ["max_matches"] = 1, + ["ignore_missing"] = true + } + }, + new JsonObject { - ["lang"] = "painless", - ["source"] = """ + ["script"] = new JsonObject + { + ["lang"] = "painless", + ["source"] = + """ def lookup = ctx._content_date_lookup; if (lookup != null && lookup.content_hash != null && lookup.content_hash == ctx.content_hash) { ctx.content_last_updated = lookup.content_last_updated; } ctx.remove('_content_date_lookup'); """ + } } - } - ) + ) }; - var response = await operations.WithRetryAsync( - () => transport.PutAsync($"/_ingest/pipeline/{PipelineName}", PostData.String(pipeline.ToJsonString()), ct), - $"PUT _ingest/pipeline/{PipelineName}", - ct - ); + var response = + await operations.WithRetryAsync( + () => transport.PutAsync($"/_ingest/pipeline/{PipelineName}", PostData.String(pipeline.ToJsonString()), ct), + $"PUT _ingest/pipeline/{PipelineName}", + ct + ); if (!response.ApiCallDetails.HasSuccessfulStatusCode) throw new InvalidOperationException( - $"Failed to create ingest pipeline {PipelineName}: {response.ApiCallDetails.DebugInformation}"); + $"Failed to create ingest pipeline {PipelineName}: {response.ApiCallDetails.DebugInformation}" + ); logger.LogInformation("Created ingest pipeline {Pipeline}", PipelineName); } private async Task RefreshIndexAsync(string indexName, Cancel ct) { - var response = await operations.WithRetryAsync( - () => transport.PostAsync($"/{indexName}/_refresh", PostData.Empty, ct), - $"POST {indexName}/_refresh", - ct - ); + var response = + await operations.WithRetryAsync( + () => transport.PostAsync($"/{indexName}/_refresh", PostData.Empty, ct), + $"POST {indexName}/_refresh", + ct + ); if (!response.ApiCallDetails.HasSuccessfulStatusCode) - throw new InvalidOperationException( - $"Failed to refresh index {indexName}: {response.ApiCallDetails.DebugInformation}"); + throw new InvalidOperationException($"Failed to refresh index {indexName}: {response.ApiCallDetails.DebugInformation}"); logger.LogInformation("Refreshed index {Index}", indexName); } @@ -402,15 +407,8 @@ private async Task ReindexToLookupAsync(string sourceAlias, string destIndex, Ca ["index"] = sourceAlias, ["_source"] = new JsonArray("url", "content_hash", "content_last_updated") }, - ["dest"] = new JsonObject - { - ["index"] = destIndex - }, - ["script"] = new JsonObject - { - ["lang"] = "painless", - ["source"] = "ctx._id = ctx._source.url.sha256().substring(0, 16)" - } + ["dest"] = new JsonObject { ["index"] = destIndex }, + ["script"] = new JsonObject { ["lang"] = "painless", ["source"] = "ctx._id = ctx._source.url.sha256().substring(0, 16)" } }; await operations.ReindexAsync(sourceAlias, PostData.String(reindexBody.ToJsonString()), destIndex, ct); diff --git a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.Export.cs b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.Export.cs index 518abda663..d50ee5cad9 100644 --- a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.Export.cs +++ b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.Export.cs @@ -30,10 +30,16 @@ private void AssignDocumentMetadata(DocumentationDocument doc) { var semanticHash = _semanticTypeContext?.Hash ?? string.Empty; var lexicalHash = _lexicalTypeContext.Hash; - var hash = HashedBulkUpdate.CreateHash(semanticHash, lexicalHash, - doc.Path, doc.Type, doc.Body ?? string.Empty, string.Join(",", doc.Headings.OrderBy(h => h)), + var hash = HashedBulkUpdate.CreateHash( + semanticHash, + lexicalHash, + doc.Path, + doc.Type, + doc.Body ?? string.Empty, + string.Join(",", doc.Headings.OrderBy(h => h)), doc.SearchTitle ?? string.Empty, - doc.Section ?? string.Empty, doc.Navigation.Depth.ToString("N0"), + doc.Section ?? string.Empty, + doc.Navigation.Depth.ToString("N0"), doc.Navigation.TableOfContents.ToString("N0"), doc.ContentTier, _fixedSynonymsHash, @@ -67,14 +73,13 @@ static bool IsReleaseNotes(string? section, string url) => // Aligns with the existing `diminish_terms` in search.yml (plugin, glossary, curator, hadoop, integration, client). static bool IsSupplementary(string? section, string url) => - ContainsAny(section, "deprecat", "plugin", "glossary") - || url.Contains("/docs/extend/", StringComparison.OrdinalIgnoreCase); + ContainsAny(section, "deprecat", "plugin", "glossary") || url.Contains("/docs/extend/", StringComparison.OrdinalIgnoreCase); // A section's root/index page is effectively that section's overview — treat it as primary // alongside pages explicitly living under a "get started"/"getting started"/"overview" section. static bool IsPrimary(INavigationItem? navigationItem, string? section) => - navigationItem is IRootNavigationItem - || ContainsAny(section, "get started", "getting started", "overview"); + navigationItem is IRootNavigationItem || + ContainsAny(section, "get started", "getting started", "overview"); static bool ContainsAny(string? value, params string[] fragments) => value is not null && fragments.Any(f => value.Contains(f, StringComparison.OrdinalIgnoreCase)); @@ -124,8 +129,7 @@ string CreateSearchTitle() // skip doc and the section var split = new[] { '/', ' ', '-', '.', '_' }; var urlComponents = new HashSet( - doc.Path.Split('/', RemoveEmptyEntries).Skip(2) - .SelectMany(c => c.Split(split, RemoveEmptyEntries)).ToArray() + doc.Path.Split('/', RemoveEmptyEntries).Skip(2).SelectMany(c => c.Split(split, RemoveEmptyEntries)).ToArray() ); var title = doc.Title; //skip tokens already part of the title we don't want to influence TF/IDF @@ -159,8 +163,12 @@ public async ValueTask ExportAsync(MarkdownExportFileContext fileContext, // input, and content hashing. docs-builder no longer feeds raw LLM-flavored Markdown into `body`. var body = PlainTextExporter.ConvertToPlainText(fileContext.Document, fileContext.BuildContext); - var headings = fileContext.Document.Descendants() - .Select(h => h.GetData("header") as string ?? string.Empty) // TODO: Confirm that 'header' data is correctly set for all HeadingBlock instances and that this extraction is reliable. + var headings = fileContext.Document + .Descendants() + .Select( + h => h.GetData("header") as string ?? string.Empty + ) // TODO: Confirm that 'header' data is correctly set for all HeadingBlock instances and that this extraction is reliable. + .Where(text => !string.IsNullOrEmpty(text)) .ToArray(); var summary = !string.IsNullOrEmpty(body) @@ -176,15 +184,16 @@ public async ValueTask ExportAsync(MarkdownExportFileContext fileContext, Path = url, Title = file.Title, SearchTitle = file.Title, //updated in CommonEnrichments + Body = body, Description = fileContext.SourceFile.YamlFrontMatter?.Description, Summary = summary, Applies = appliesTo.ToAppliesTo(), - Parents = navigation.GetParentsOfMarkdownFile(file).Select(i => new ParentDocument - { - Title = i.NavigationTitle, - Path = i.Url - }).Reverse().ToArray(), + Parents = + navigation.GetParentsOfMarkdownFile(file) + .Select(i => new ParentDocument { Title = i.NavigationTitle, Path = i.Url }) + .Reverse() + .ToArray(), Headings = headings, Hidden = fileContext.NavigationItem.ExcludeFromIndexing }; @@ -200,22 +209,20 @@ public async ValueTask ExportAsync(MarkdownExportFileContext fileContext, ); doc.Product = inference.Product?.Id; doc.RelatedProducts = inference.RelatedProducts.Count > 0 - ? inference.RelatedProducts.Select(p => new IndexedProduct - { - Id = p.Id, - Repository = p.Repository ?? inference.Repository - }).ToArray() + ? inference.RelatedProducts + .Select(p => new IndexedProduct { Id = p.Id, Repository = p.Repository ?? inference.Repository }) + .ToArray() : null; var gitHubRepo = fileContext.BuildContext.Git.GitHubRepository; var branch = fileContext.BuildContext.Git.Branch; - if (gitHubRepo is not null - && fileContext.BuildContext.Git != GitCheckoutInformation.Unavailable) + if (gitHubRepo is not null && fileContext.BuildContext.Git != GitCheckoutInformation.Unavailable) { var checkoutDirectory = fileContext.BuildContext.DocumentationCheckoutDirectory; var relativeSourcePath = Path.GetRelativePath( checkoutDirectory.FullName, - fileContext.BuildContext.DocumentationSourceDirectory.FullName); + fileContext.BuildContext.DocumentationSourceDirectory.FullName + ); var path = UrlPath.Join(relativeSourcePath, file.RelativePath); doc.SourceUrl = $"https://github.com/{gitHubRepo}/blob/{branch}/{path}"; } @@ -251,7 +258,10 @@ public async ValueTask FinishExportAsync(IDirectoryInfo outputFolder, Canc doc.Body = PlainTextExporter.ConvertToPlainText(document, _context); var headings = document.Descendants() - .Select(h => h.GetData("header") as string ?? string.Empty) // TODO: Confirm that 'header' data is correctly set for all HeadingBlock instances and that this extraction is reliable. + .Select( + h => h.GetData("header") as string ?? string.Empty + ) // TODO: Confirm that 'header' data is correctly set for all HeadingBlock instances and that this extraction is reliable. + .Where(text => !string.IsNullOrEmpty(text)) .ToArray(); var summary = !string.IsNullOrEmpty(doc.Body) @@ -273,5 +283,4 @@ public async ValueTask FinishExportAsync(IDirectoryInfo outputFolder, Canc _logger.LogInformation("Finished exporting OpenAPI documentation"); return true; } - } diff --git a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs index ac9e0a559f..f63a584612 100644 --- a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs +++ b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs @@ -92,24 +92,22 @@ IDocumentationConfigurationContext context var synonymSetName = $"docs-{_buildType}-{_environment}"; - _lexicalTypeContext = DocumentationMappingContext.DocumentationDocument - .CreateContext(type: _buildType, env: endpoints.Environment) with + _lexicalTypeContext = DocumentationMappingContext.DocumentationDocument.CreateContext( + type: _buildType, + env: endpoints.Environment + ) with { ConfigureAnalysis = a => SharedAnalysisFactory.BuildAnalysis(a, synonymSetName, indexTimeSynonyms), - IndexSettings = new Dictionary - { - ["index.default_pipeline"] = _contentDateEnrichment.PipelineName - } + IndexSettings = new Dictionary { ["index.default_pipeline"] = _contentDateEnrichment.PipelineName } }; - _semanticTypeContext = DocumentationMappingContext.DocumentationDocumentSemantic - .CreateContext(type: _buildType, env: endpoints.Environment) with + _semanticTypeContext = DocumentationMappingContext.DocumentationDocumentSemantic.CreateContext( + type: _buildType, + env: endpoints.Environment + ) with { ConfigureAnalysis = a => SharedAnalysisFactory.BuildAnalysis(a, synonymSetName, indexTimeSynonyms), - IndexSettings = new Dictionary - { - ["index.final_pipeline"] = _contentDateEnrichment.PipelineName - } + IndexSettings = new Dictionary { ["index.final_pipeline"] = _contentDateEnrichment.PipelineName } }; if (es.EnableAiEnrichment) @@ -119,7 +117,10 @@ IDocumentationConfigurationContext context var infra = provider.CreateInfrastructure($"{_semanticTypeContext.IndexStrategy!.WriteTarget}-ai-cache"); _logger.LogInformation( "AI enrichment enabled — pipeline: {Pipeline}, policy: {Policy}, lookup: {Lookup}", - infra.PipelineName, infra.EnrichPolicyName, infra.LookupIndexName); + infra.PipelineName, + infra.EnrichPolicyName, + infra.LookupIndexName + ); var semanticSettings = new Dictionary(_semanticTypeContext.IndexSettings ?? new Dictionary()) { @@ -136,37 +137,54 @@ IDocumentationConfigurationContext context { ConfigurePrimary = opts => ConfigureChannelOptions("primary", opts), ConfigureSecondary = opts => ConfigureChannelOptions("secondary", opts), - OnPostComplete = _aiEnrichment is not null - ? async (ctx, _, ct) => await PostCompleteAsync(ctx, ct) - : null, - OnRolloverDecision = info => - _logger.LogInformation( - "[{Label}] rollover={RolledOver}, localHash={LocalHash}, remoteHash={RemoteHash}", - info.Label, info.RolledOver, info.LocalHash, info.RemoteHash), - OnReindexProgress = (label, p) => - _logger.LogInformation( - "[{Label}] total={Total} created={Created} updated={Updated} deleted={Deleted} noops={Noops} completed={IsCompleted}", - label, p.Total, p.Created, p.Updated, p.Deleted, p.Noops, p.IsCompleted), - OnDeleteByQueryProgress = (label, p) => - _logger.LogInformation( - "[{Label}] total={Total} deleted={Deleted} completed={IsCompleted}", - label, p.Total, p.Deleted, p.IsCompleted) + OnPostComplete = _aiEnrichment is not null ? async (ctx, _, ct) => await PostCompleteAsync(ctx, ct) : null, + OnRolloverDecision = + info => + _logger.LogInformation( + "[{Label}] rollover={RolledOver}, localHash={LocalHash}, remoteHash={RemoteHash}", + info.Label, + info.RolledOver, + info.LocalHash, + info.RemoteHash + ), + OnReindexProgress = + (label, p) => + _logger.LogInformation( + "[{Label}] total={Total} created={Created} updated={Updated} deleted={Deleted} noops={Noops} completed={IsCompleted}", + label, + p.Total, + p.Created, + p.Updated, + p.Deleted, + p.Noops, + p.IsCompleted + ), + OnDeleteByQueryProgress = + (label, p) => + _logger.LogInformation( + "[{Label}] total={Total} deleted={Deleted} completed={IsCompleted}", + label, + p.Total, + p.Deleted, + p.IsCompleted + ) }; - _ = _orchestrator.AddPreBootstrapTask(async (_, ct) => - { - _logger.LogInformation("Initializing content date enrichment infrastructure..."); - await _contentDateEnrichment.InitializeAsync(ct); - _logger.LogInformation("Content date enrichment infrastructure ready"); - - if (_aiEnrichment is not null) + _ = + _orchestrator.AddPreBootstrapTask(async (_, ct) => { - _logger.LogInformation("Initializing AI enrichment infrastructure..."); - await _aiEnrichment.InitializeAsync(ct); - _logger.LogInformation("AI enrichment infrastructure ready"); - } - await PublishSynonymsAsync(ct); - await PublishQueryRulesAsync(ct); - }); + _logger.LogInformation("Initializing content date enrichment infrastructure..."); + await _contentDateEnrichment.InitializeAsync(ct); + _logger.LogInformation("Content date enrichment infrastructure ready"); + + if (_aiEnrichment is not null) + { + _logger.LogInformation("Initializing AI enrichment infrastructure..."); + await _aiEnrichment.InitializeAsync(ct); + _logger.LogInformation("AI enrichment infrastructure ready"); + } + await PublishSynonymsAsync(ct); + await PublishQueryRulesAsync(ct); + }); } private void ConfigureChannelOptions(string label, IngestChannelOptions options) @@ -185,8 +203,7 @@ private void ConfigureChannelOptions(string label, IngestChannelOptions @@ -251,7 +272,11 @@ private async Task PostCompleteAsync(OrchestratorContext if (last is not null) _logger.LogInformation( "AI enrichment complete in {Elapsed}: {Enriched} enriched, {Failed} failed, {Candidates} candidates", - sw.Elapsed.ToString(@"hh\:mm\:ss"), last.Enriched, last.Failed, last.TotalCandidates); + sw.Elapsed.ToString(@"hh\:mm\:ss"), + last.Enriched, + last.Failed, + last.TotalCandidates + ); } /// Whether AI enrichment infrastructure is wired up for this endpoint. @@ -281,9 +306,7 @@ private async Task PublishSynonymsAsync(Cancel ctx) var setName = $"docs-{_buildType}-{_environment}"; _logger.LogInformation("Publishing synonym set '{SetName}' to Elasticsearch", setName); - var synonymRules = _synonyms - .Select(s => new SynonymRule { Id = s[0], Synonyms = string.Join(", ", s) }) - .ToList(); + var synonymRules = _synonyms.Select(s => new SynonymRule { Id = s[0], Synonyms = string.Join(", ", s) }).ToList(); var synonymsSet = new SynonymsSet { Synonyms = synonymRules }; await PutSynonyms(synonymsSet, setName, ctx); @@ -293,14 +316,17 @@ private async Task PutSynonyms(SynonymsSet synonymsSet, string setName, Cancel c { var json = JsonSerializer.Serialize(synonymsSet, SynonymSerializerContext.Default.SynonymsSet); - var response = await _operations.WithRetryAsync( - () => _transport.PutAsync($"_synonyms/{setName}", PostData.String(json), ctx), - $"PUT _synonyms/{setName}", - ctx); + var response = + await _operations.WithRetryAsync( + () => _transport.PutAsync($"_synonyms/{setName}", PostData.String(json), ctx), + $"PUT _synonyms/{setName}", + ctx + ); if (!response.ApiCallDetails.HasSuccessfulStatusCode) _collector.EmitGlobalError( - $"Failed to publish synonym set '{setName}'. Reason: {response.ApiCallDetails.OriginalException?.Message ?? response.ToString()}"); + $"Failed to publish synonym set '{setName}'. Reason: {response.ApiCallDetails.OriginalException?.Message ?? response.ToString()}" + ); else _logger.LogInformation("Successfully published synonym set '{SetName}'.", setName); } @@ -316,18 +342,27 @@ private async Task PublishQueryRulesAsync(Cancel ctx) var rulesetName = $"docs-ruleset-{_buildType}-{_environment}"; _logger.LogInformation("Publishing query ruleset '{RulesetName}' with {Count} rules to Elasticsearch", rulesetName, _rules.Count); - var rulesetRules = _rules.Select(r => new QueryRulesetRule - { - RuleId = r.RuleId, - Type = r.Type.ToString().ToLowerInvariant(), - Criteria = r.Criteria.Select(c => new QueryRulesetCriteria - { - Type = c.Type.ToString().ToLowerInvariant(), - Metadata = c.Metadata, - Values = c.Values.ToList() - }).ToList(), - Actions = new QueryRulesetActions { Ids = r.Actions.Ids.ToList() } - }).ToList(); + var rulesetRules = _rules.Select( + r => + new QueryRulesetRule + { + RuleId = r.RuleId, + Type = r.Type.ToString().ToLowerInvariant(), + Criteria = + r.Criteria + .Select( + c => + new QueryRulesetCriteria + { + Type = c.Type.ToString().ToLowerInvariant(), + Metadata = c.Metadata, + Values = c.Values.ToList() + } + ) + .ToList(), + Actions = new QueryRulesetActions { Ids = r.Actions.Ids.ToList() } + } + ).ToList(); var ruleset = new QueryRuleset { Rules = rulesetRules }; await PutQueryRuleset(ruleset, rulesetName, ctx); @@ -337,14 +372,17 @@ private async Task PutQueryRuleset(QueryRuleset ruleset, string rulesetName, Can { var json = JsonSerializer.Serialize(ruleset, QueryRulesetSerializerContext.Default.QueryRuleset); - var response = await _operations.WithRetryAsync( - () => _transport.PutAsync($"_query_rules/{rulesetName}", PostData.String(json), ctx), - $"PUT _query_rules/{rulesetName}", - ctx); + var response = + await _operations.WithRetryAsync( + () => _transport.PutAsync($"_query_rules/{rulesetName}", PostData.String(json), ctx), + $"PUT _query_rules/{rulesetName}", + ctx + ); if (!response.ApiCallDetails.HasSuccessfulStatusCode) _collector.EmitGlobalError( - $"Failed to publish query ruleset '{rulesetName}'. Reason: {response.ApiCallDetails.OriginalException?.Message ?? response.ToString()}"); + $"Failed to publish query ruleset '{rulesetName}'. Reason: {response.ApiCallDetails.OriginalException?.Message ?? response.ToString()}" + ); else _logger.LogInformation("Successfully published query ruleset '{RulesetName}'.", rulesetName); } diff --git a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchOperations.cs b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchOperations.cs index b69846c9f6..6a1dd67587 100644 --- a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchOperations.cs +++ b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchOperations.cs @@ -15,11 +15,7 @@ namespace Elastic.Markdown.Exporters.Elasticsearch; /// Provides common infrastructure for ES API calls with exponential backoff on 429 errors /// and polling for async operations (delete_by_query, reindex, update_by_query). /// -public class ElasticsearchOperations( - ITransport transport, - ILogger logger, - IDiagnosticsCollector? collector = null, - int maxRetries = 5) +public class ElasticsearchOperations(ITransport transport, ILogger logger, IDiagnosticsCollector? collector = null, int maxRetries = 5) { private readonly ITransport _transport = transport; private readonly ILogger _logger = logger; @@ -32,7 +28,8 @@ public class ElasticsearchOperations( public async Task WithRetryAsync( Func> apiCall, string operationName, - CancellationToken ct) where TResponse : TransportResponse + CancellationToken ct + ) where TResponse : TransportResponse { for (var attempt = 0; attempt <= _maxRetries; attempt++) { @@ -49,7 +46,12 @@ public async Task WithRetryAsync( var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); // 1s, 2s, 4s, 8s, 16s _logger.LogWarning( "Retryable error ({StatusCode}) on {Operation}, retrying in {Delay}s (attempt {Attempt}/{MaxRetries})", - statusCode, operationName, delay.TotalSeconds, attempt + 1, _maxRetries); + statusCode, + operationName, + delay.TotalSeconds, + attempt + 1, + _maxRetries + ); await Task.Delay(delay, ct); continue; } @@ -71,17 +73,16 @@ public async Task PollTaskUntilCompleteAsync( string sourceIndex, string? destIndex, CancellationToken ct, - TimeSpan? maxDuration = null) + TimeSpan? maxDuration = null + ) { maxDuration ??= TimeSpan.FromMinutes(30); var sw = Stopwatch.StartNew(); bool completed; do { - var taskResponse = await WithRetryAsync( - () => _transport.GetAsync($"/_tasks/{taskId}", ct), - $"GET _tasks/{taskId}", - ct); + var taskResponse = + await WithRetryAsync(() => _transport.GetAsync($"/_tasks/{taskId}", ct), $"GET _tasks/{taskId}", ct); completed = taskResponse.Body.Get("completed"); var total = taskResponse.Body.Get("task.status.total"); @@ -96,31 +97,54 @@ public async Task PollTaskUntilCompleteAsync( { _logger.LogInformation( "{Operation}: {Time} '{SourceIndex}' => '{DestIndex}'. Documents {Total}: {Updated} updated, {Created} created, {Deleted} deleted, {Batches} batches", - operation, time.ToString(@"hh\:mm\:ss"), sourceIndex, destIndex, total, updated, created, deleted, batches); + operation, + time.ToString(@"hh\:mm\:ss"), + sourceIndex, + destIndex, + total, + updated, + created, + deleted, + batches + ); } else { _logger.LogInformation( "{Operation} '{SourceIndex}': {Time} Documents {Total}: {Updated} updated, {Created} created, {Deleted} deleted, {Batches} batches", - operation, sourceIndex, time.ToString(@"hh\:mm\:ss"), total, updated, created, deleted, batches); + operation, + sourceIndex, + time.ToString(@"hh\:mm\:ss"), + total, + updated, + created, + deleted, + batches + ); } if (!completed && sw.Elapsed > maxDuration.Value) { _logger.LogWarning( "Task {TaskId} for {Operation} on '{SourceIndex}' exceeded max duration {MaxDuration} (elapsed: {Elapsed}). Attempting to cancel", - taskId, operation, sourceIndex, maxDuration.Value, sw.Elapsed); + taskId, + operation, + sourceIndex, + maxDuration.Value, + sw.Elapsed + ); await CancelTaskBestEffortAsync(taskId, operation, ct); throw new TimeoutException( - $"Elasticsearch task {taskId} for {operation} on '{sourceIndex}' did not complete within {maxDuration.Value}"); + $"Elasticsearch task {taskId} for {operation} on '{sourceIndex}' did not complete within {maxDuration.Value}" + ); } if (!completed) await Task.Delay(TimeSpan.FromSeconds(5), ct); - - } while (!completed); + } + while (!completed); } /// Attempts to cancel an ES task with a short timeout. Logs on failure but does not throw. @@ -131,15 +155,17 @@ private async Task CancelTaskBestEffortAsync(string taskId, string operation, Ca try { - var response = await _transport.PostAsync( - $"/_tasks/{taskId}/_cancel", PostData.Empty, cts.Token); + var response = await _transport.PostAsync($"/_tasks/{taskId}/_cancel", PostData.Empty, cts.Token); if (response.ApiCallDetails.HasSuccessfulStatusCode) _logger.LogInformation("Successfully requested cancellation of task {TaskId} ({Operation})", taskId, operation); else _logger.LogWarning( "Cancel request for task {TaskId} ({Operation}) returned status {StatusCode}", - taskId, operation, response.ApiCallDetails.HttpStatusCode); + taskId, + operation, + response.ApiCallDetails.HttpStatusCode + ); } catch (Exception ex) { @@ -152,16 +178,9 @@ private async Task CancelTaskBestEffortAsync(string taskId, string operation, Ca /// Use with wait_for_completion=false URLs. /// /// Task ID if successful, null if failed - public async Task PostAsyncTaskAsync( - string url, - PostData request, - string operationName, - CancellationToken ct) + public async Task PostAsyncTaskAsync(string url, PostData request, string operationName, CancellationToken ct) { - var response = await WithRetryAsync( - () => _transport.PostAsync(url, request, ct), - operationName, - ct); + var response = await WithRetryAsync(() => _transport.PostAsync(url, request, ct), operationName, ct); var taskId = response.Body.Get("task"); if (string.IsNullOrWhiteSpace(taskId)) @@ -179,10 +198,7 @@ private async Task CancelTaskBestEffortAsync(string taskId, string operation, Ca /// Executes a delete_by_query operation asynchronously (fire-and-forget). /// Returns the task ID without waiting for completion. /// - public async Task DeleteByQueryFireAndForgetAsync( - string index, - PostData query, - CancellationToken ct) + public async Task DeleteByQueryFireAndForgetAsync(string index, PostData query, CancellationToken ct) { var url = $"/{index}/_delete_by_query?wait_for_completion=false"; return await PostAsyncTaskAsync(url, query, $"POST {index}/_delete_by_query", ct); @@ -191,14 +207,10 @@ private async Task CancelTaskBestEffortAsync(string taskId, string operation, Ca /// /// Executes a delete_by_query operation and waits for completion. /// - public async Task DeleteByQueryAsync( - string index, - PostData query, - CancellationToken ct, - TimeSpan? maxDuration = null) + public async Task DeleteByQueryAsync(string index, PostData query, CancellationToken ct, TimeSpan? maxDuration = null) { - var taskId = await DeleteByQueryFireAndForgetAsync(index, query, ct) - ?? throw new InvalidOperationException($"Failed to start _delete_by_query on {index}"); + var taskId = await DeleteByQueryFireAndForgetAsync(index, query, ct) ?? + throw new InvalidOperationException($"Failed to start _delete_by_query on {index}"); await PollTaskUntilCompleteAsync(taskId, "_delete_by_query", index, null, ct, maxDuration); } @@ -210,28 +222,24 @@ public async Task ReindexAsync( PostData request, string destIndex, CancellationToken ct, - TimeSpan? maxDuration = null) + TimeSpan? maxDuration = null + ) { var url = "/_reindex?wait_for_completion=false"; - var taskId = await PostAsyncTaskAsync(url, request, $"POST _reindex ({sourceIndex} => {destIndex})", ct) - ?? throw new InvalidOperationException($"Failed to start _reindex ({sourceIndex} => {destIndex})"); + var taskId = await PostAsyncTaskAsync(url, request, $"POST _reindex ({sourceIndex} => {destIndex})", ct) ?? + throw new InvalidOperationException($"Failed to start _reindex ({sourceIndex} => {destIndex})"); await PollTaskUntilCompleteAsync(taskId, "_reindex", sourceIndex, destIndex, ct, maxDuration); } /// /// Executes an update_by_query operation and waits for completion. /// - public async Task UpdateByQueryAsync( - string index, - PostData query, - string? pipeline, - CancellationToken ct, - TimeSpan? maxDuration = null) + public async Task UpdateByQueryAsync(string index, PostData query, string? pipeline, CancellationToken ct, TimeSpan? maxDuration = null) { var pipelineParam = pipeline is not null ? $"&pipeline={pipeline}" : ""; var url = $"/{index}/_update_by_query?wait_for_completion=false{pipelineParam}"; - var taskId = await PostAsyncTaskAsync(url, query, $"POST {index}/_update_by_query", ct) - ?? throw new InvalidOperationException($"Failed to start _update_by_query on {index}"); + var taskId = await PostAsyncTaskAsync(url, query, $"POST {index}/_update_by_query", ct) ?? + throw new InvalidOperationException($"Failed to start _update_by_query on {index}"); await PollTaskUntilCompleteAsync(taskId, "_update_by_query", index, null, ct, maxDuration); } } diff --git a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchTransportFactory.cs b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchTransportFactory.cs index 78652bd1d9..5fe8407b9a 100644 --- a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchTransportFactory.cs +++ b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchTransportFactory.cs @@ -19,9 +19,7 @@ public static DistributedTransport Create(ElasticsearchEndpoint endpoint) { Authentication = endpoint.ApiKey is { } apiKey ? new ApiKey(apiKey) - : endpoint is { Username: { } username, Password: { } password } - ? new BasicAuthentication(username, password) - : null, + : endpoint is { Username: { } username, Password: { } password } ? new BasicAuthentication(username, password) : null, EnableHttpCompression = true, DebugMode = endpoint.DebugMode, CertificateFingerprint = endpoint.CertificateFingerprint, diff --git a/src/Elastic.Markdown/Exporters/LlmMarkdownExporter.cs b/src/Elastic.Markdown/Exporters/LlmMarkdownExporter.cs index 67584f10ef..e64285c356 100644 --- a/src/Elastic.Markdown/Exporters/LlmMarkdownExporter.cs +++ b/src/Elastic.Markdown/Exporters/LlmMarkdownExporter.cs @@ -31,7 +31,8 @@ namespace Elastic.Markdown.Exporters; public class LlmMarkdownExporter(bool branded = false, DocumentationWriteFileSystem? writeFileSystem = null) : IMarkdownExporter { - private const string LlmsTxtTemplate = """ + private const string LlmsTxtTemplate = + """ # Elastic Documentation > Elastic provides an open source search, analytics, and AI platform, and out-of-the-box solutions for observability and security. The Search AI platform combines the power of search and generative AI to provide near real-time search and analysis with relevance to reduce your time to value. @@ -103,20 +104,19 @@ public async ValueTask ExportAsync(MarkdownExportFileContext fileContext, var content = !branded && IsRootIndexFile(fileContext) ? LlmsTxtTemplate : CreateLlmContentWithMetadata(fileContext, llmMarkdown); - await fs.File.WriteAllTextAsync( - outputFile.FullName, - content, - Encoding.UTF8, - ctx - ); + await fs.File.WriteAllTextAsync(outputFile.FullName, content, Encoding.UTF8, ctx); return true; } public static string ConvertToLlmMarkdown(MarkdownDocument document, IDocumentationConfigurationContext context) => - DocumentationObjectPoolProvider.UseLlmMarkdownRenderer(context, document, static (renderer, obj) => - { - _ = renderer.Render(obj); - }); + DocumentationObjectPoolProvider.UseLlmMarkdownRenderer( + context, + document, + static (renderer, obj) => + { + _ = renderer.Render(obj); + } + ); private static bool IsRootIndexFile(MarkdownExportFileContext fileContext) { @@ -144,10 +144,7 @@ private static IFileInfo GetLlmOutputFile(IFileSystem writeFileSystem, MarkdownE // For index files: /docs/section/index.html -> /docs/section.md // This allows users to append .md to any URL path var folderName = defaultOutputFile.Directory!.Name; - return writeFileSystem.FileInfo.New(Path.Join( - defaultOutputFile.Directory!.Parent!.FullName, - $"{folderName}.md" - )); + return writeFileSystem.FileInfo.New(Path.Join(defaultOutputFile.Directory!.Parent!.FullName, $"{folderName}.md")); } // Regular files: /docs/section/page.html -> /docs/section/page.llm.md var directory = defaultOutputFile.Directory!.FullName; @@ -155,7 +152,6 @@ private static IFileInfo GetLlmOutputFile(IFileSystem writeFileSystem, MarkdownE return writeFileSystem.FileInfo.New(Path.Join(directory, $"{baseName}.md")); } - private static string CreateLlmContentWithMetadata(MarkdownExportFileContext context, string llmMarkdown) { var sourceFile = context.SourceFile; @@ -216,7 +212,6 @@ private static string CreateLlmContentWithMetadata(MarkdownExportFileContext con return metadata.ToString(); } - private static List GetAppliesToItems(ApplicableTo appliesTo, IDocumentationConfigurationContext buildContext) { var viewModel = new ApplicableToViewModel diff --git a/src/Elastic.Markdown/Exporters/NoopDocumentationFileExporter.cs b/src/Elastic.Markdown/Exporters/NoopDocumentationFileExporter.cs index 391e88c6f0..d93bc8adbb 100644 --- a/src/Elastic.Markdown/Exporters/NoopDocumentationFileExporter.cs +++ b/src/Elastic.Markdown/Exporters/NoopDocumentationFileExporter.cs @@ -10,8 +10,7 @@ public class NoopDocumentationFileExporter : IDocumentationFileExporter { public string Name { get; } = nameof(NoopDocumentationFileExporter); - public ValueTask ProcessFile(ProcessingFileContext context, Cancel ctx) => - ValueTask.CompletedTask; + public ValueTask ProcessFile(ProcessingFileContext context, Cancel ctx) => ValueTask.CompletedTask; public Task CopyEmbeddedResource(IFileInfo outputFile, Stream resourceStream, Cancel ctx) => Task.CompletedTask; } diff --git a/src/Elastic.Markdown/Exporters/OkfMarkdownExporter.cs b/src/Elastic.Markdown/Exporters/OkfMarkdownExporter.cs index 4c32a4dc72..db6e8fde28 100644 --- a/src/Elastic.Markdown/Exporters/OkfMarkdownExporter.cs +++ b/src/Elastic.Markdown/Exporters/OkfMarkdownExporter.cs @@ -162,10 +162,12 @@ internal static string DeriveType(string navigationUrl, string? urlPathPrefix) if (string.IsNullOrEmpty(url)) return url; - if (canonicalBaseUrl is not null + if ( + canonicalBaseUrl is not null && Uri.TryCreate(url, UriKind.Absolute, out var absolute) && string.Equals(absolute.Scheme, canonicalBaseUrl.Scheme, StringComparison.OrdinalIgnoreCase) - && string.Equals(absolute.Host, canonicalBaseUrl.Host, StringComparison.OrdinalIgnoreCase)) + && string.Equals(absolute.Host, canonicalBaseUrl.Host, StringComparison.OrdinalIgnoreCase) + ) url = absolute.PathAndQuery + absolute.Fragment; if (Uri.IsWellFormedUriString(url, UriKind.Absolute)) @@ -200,10 +202,15 @@ private static string CreateConceptContent(MarkdownExportFileContext context) { var urlPathPrefix = context.BuildContext.UrlPathPrefix; var canonicalBaseUrl = context.BuildContext.CanonicalBaseUrl; - var body = DocumentationObjectPoolProvider.UseLlmMarkdownRenderer(context.BuildContext, url => RewriteLinkUrl(url, urlPathPrefix, canonicalBaseUrl), context.Document, static (renderer, document) => - { - _ = renderer.Render(document); - }); + var body = DocumentationObjectPoolProvider.UseLlmMarkdownRenderer( + context.BuildContext, + url => RewriteLinkUrl(url, urlPathPrefix, canonicalBaseUrl), + context.Document, + static (renderer, document) => + { + _ = renderer.Render(document); + } + ); var sourceFile = context.SourceFile; var frontMatter = DocumentationObjectPoolProvider.StringBuilderPool.Get(); @@ -297,9 +304,11 @@ private static List GetAppliesToItems(ApplicableTo appliesTo, Elastic.Do /// private async Task WriteIndexFilesAsync(IFileSystem fs, IDirectoryInfo staging, Cancel ctx) { - var byDirectory = _entries - .GroupBy(e => GetDirectory(e.BundlePath), StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal); + var byDirectory = _entries.GroupBy(e => GetDirectory(e.BundlePath), StringComparer.Ordinal).ToDictionary( + g => g.Key, + g => g.ToList(), + StringComparer.Ordinal + ); var directories = new HashSet(byDirectory.Keys, StringComparer.Ordinal) { "" }; foreach (var directory in byDirectory.Keys) @@ -314,8 +323,7 @@ private async Task WriteIndexFilesAsync(IFileSystem fs, IDirectoryInfo staging, } } - var subdirectoriesByParent = directories - .Where(d => d.Length > 0) + var subdirectoriesByParent = directories.Where(d => d.Length > 0) .GroupBy(GetDirectory, StringComparer.Ordinal) .ToDictionary(g => g.Key, g => g.OrderBy(d => d, StringComparer.Ordinal).ToList(), StringComparer.Ordinal); @@ -343,7 +351,8 @@ internal static string GetDirectory(string bundlePath) internal static string RenderIndexContent( string directory, IReadOnlyCollection concepts, - IReadOnlyCollection subdirectories) + IReadOnlyCollection subdirectories + ) { var sb = DocumentationObjectPoolProvider.StringBuilderPool.Get(); try diff --git a/src/Elastic.Markdown/Exporters/Pagefind/PagefindMarkdownExporter.cs b/src/Elastic.Markdown/Exporters/Pagefind/PagefindMarkdownExporter.cs index 99ac6da081..f97686ebfe 100644 --- a/src/Elastic.Markdown/Exporters/Pagefind/PagefindMarkdownExporter.cs +++ b/src/Elastic.Markdown/Exporters/Pagefind/PagefindMarkdownExporter.cs @@ -61,12 +61,7 @@ public ValueTask ExportAsync(MarkdownExportFileContext fileContext, Cancel try { - _index.AddHtmlRecord(new HtmlPageData - { - Url = url, - Sections = sections, - Meta = meta - }); + _index.AddHtmlRecord(new HtmlPageData { Url = url, Sections = sections, Meta = meta }); _indexed++; } catch (PagefindIndexingException ex) @@ -153,8 +148,7 @@ public async ValueTask FinishExportAsync(IDirectoryInfo outputFolder, Canc var staticDir = Path.Combine(outputFolder.FullName, "_static"); await _index.WriteAsync(staticDir, ctx); var pagefindDir = Path.Combine(staticDir, "pagefind"); - _ = await PagefindFrontend.ExtractToAsync( - _fileSystem!, pagefindDir, force: false, ctx); + _ = await PagefindFrontend.ExtractToAsync(_fileSystem!, pagefindDir, force: false, ctx); _logger.LogInformation("Generated static search index with {Count} pages", _indexed); return true; diff --git a/src/Elastic.Markdown/Exporters/PlainTextExporter.cs b/src/Elastic.Markdown/Exporters/PlainTextExporter.cs index 892c401f1b..e323928999 100644 --- a/src/Elastic.Markdown/Exporters/PlainTextExporter.cs +++ b/src/Elastic.Markdown/Exporters/PlainTextExporter.cs @@ -21,17 +21,25 @@ public static class PlainTextExporter /// The documentation configuration context /// Plain text representation of the document public static string ConvertToPlainText(MarkdownDocument document, IDocumentationConfigurationContext context) => - DocumentationObjectPoolProvider.UsePlainTextRenderer(context, document, static (renderer, doc) => - { - _ = renderer.Render(doc); - }); + DocumentationObjectPoolProvider.UsePlainTextRenderer( + context, + document, + static (renderer, doc) => + { + _ = renderer.Render(doc); + } + ); /// /// Converts a single markdown block to plain text suitable for search indexing. /// public static string ConvertBlockToPlainText(Block block, IDocumentationConfigurationContext context) => - DocumentationObjectPoolProvider.UsePlainTextRenderer(context, block, static (renderer, b) => - { - _ = renderer.Render(b); - }); + DocumentationObjectPoolProvider.UsePlainTextRenderer( + context, + block, + static (renderer, b) => + { + _ = renderer.Render(b); + } + ); } diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliAliasFile.cs b/src/Elastic.Markdown/Extensions/CliReference/CliAliasFile.cs index d5d3de981b..d04430a925 100644 --- a/src/Elastic.Markdown/Extensions/CliReference/CliAliasFile.cs +++ b/src/Elastic.Markdown/Extensions/CliReference/CliAliasFile.cs @@ -49,6 +49,5 @@ protected override Task GetParseDocumentAsync(Cancel ctx) return Task.FromResult(MarkdownParser.ParseStringAsync(markdown, SourceFile, null)); } - private string BuildMarkdown() => - CliMarkdownGenerator.AliasPage(_shortcut, _binaryName, _canonicalRelativePath); + private string BuildMarkdown() => CliMarkdownGenerator.AliasPage(_shortcut, _binaryName, _canonicalRelativePath); } diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliCommandFile.cs b/src/Elastic.Markdown/Extensions/CliReference/CliCommandFile.cs index f77f9ed921..38704d30ec 100644 --- a/src/Elastic.Markdown/Extensions/CliReference/CliCommandFile.cs +++ b/src/Elastic.Markdown/Extensions/CliReference/CliCommandFile.cs @@ -71,9 +71,17 @@ private string BuildMarkdown() ? _supplementalDoc.FileSystem.File.ReadAllText(_supplementalDoc.FullName) : null; var supplemental = CliSupplementalDoc.Parse(rawSupplemental); - var body = CliMarkdownGenerator.CommandPage(_command, supplemental, _fullPath, _binaryName, _reservedMetaCommands, + var body = CliMarkdownGenerator.CommandPage( + _command, + supplemental, + _fullPath, + _binaryName, + _reservedMetaCommands, error => Collector.EmitError(_supplementalDoc ?? SourceFile, error), - _ancestorNamespaceOptions, _globalOptions, _shortcuts); + _ancestorNamespaceOptions, + _globalOptions, + _shortcuts + ); // Prepend supplemental front matter so applies_to (or any other field) in cmd-*.md overrides the fallback return supplemental?.FrontMatter is { } fm ? $"{fm}\n\n{body}" : body; } diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs b/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs index 709964530e..168fe5e7ef 100644 --- a/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs +++ b/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs @@ -107,7 +107,8 @@ public static string NamespacePage( string? binaryName = null, string[]? reservedMetaCommands = null, Action? emitError = null, - List? shortcuts = null) + List? shortcuts = null + ) { var sb = new StringBuilder(); AppendFrontMatter(sb, supplemental); @@ -198,7 +199,8 @@ public static string CommandPage( Action? emitError = null, IReadOnlyList<(string Segment, List? Options)>? ancestorNamespaceOptions = null, List? globalOptions = null, - List? shortcuts = null) + List? shortcuts = null + ) { var sb = new StringBuilder(); AppendFrontMatter(sb, supplemental); @@ -212,9 +214,7 @@ public static string CommandPage( ? CleanUsage(cmd.Usage, reservedMetaCommands) : GenerateUsage(cmd, fullPath, binaryName); - var allUsages = new[] { canonicalUsage } - .Concat(AliasUsages(fullPath, binaryName, canonicalUsage, shortcuts)) - .ToList(); + var allUsages = new[] { canonicalUsage }.Concat(AliasUsages(fullPath, binaryName, canonicalUsage, shortcuts)).ToList(); var shortestUsage = allUsages.MinBy(u => u.Length) ?? canonicalUsage; var otherUsages = allUsages.Where(u => u != shortestUsage).ToList(); @@ -237,9 +237,7 @@ public static string CommandPage( _ = sb.AppendLine(); } - var behaviorParams = cmd.Parameters - .Where(p => p.Role is "dryRun" or "confirmationSkip" or "output" && !p.Hidden) - .ToList(); + var behaviorParams = cmd.Parameters.Where(p => p.Role is "dryRun" or "confirmationSkip" or "output" && !p.Hidden).ToList(); if (behaviorParams.Count > 0) AppendBehaviorParams(sb, behaviorParams); @@ -268,10 +266,12 @@ public static string CommandPage( { var flagNames = new HashSet( cmd.Parameters.Where(p => p.Role != "positional").Select(p => p.Name), - StringComparer.OrdinalIgnoreCase); + StringComparer.OrdinalIgnoreCase + ); var positionalNames = new HashSet( cmd.Parameters.Where(p => p.Role == "positional").Select(p => p.Name), - StringComparer.OrdinalIgnoreCase); + StringComparer.OrdinalIgnoreCase + ); foreach (var key in supplemental.OptionOverrides.Keys) { if (!flagNames.Contains(key)) @@ -397,8 +397,10 @@ private static void AppendBehaviorParams(StringBuilder sb, List var flagName = p.Role == "positional" ? $"`<{p.Name}>`" : $"`--{p.Name}`"; var desc = p.Role switch { - "dryRun" => !string.IsNullOrWhiteSpace(p.Summary) ? CleanSummary(p.Summary).description : "Preview changes without applying them.", - "confirmationSkip" => !string.IsNullOrWhiteSpace(p.Summary) ? CleanSummary(p.Summary).description : "Skip the confirmation prompt.", + "dryRun" => + !string.IsNullOrWhiteSpace(p.Summary) ? CleanSummary(p.Summary).description : "Preview changes without applying them.", + "confirmationSkip" => + !string.IsNullOrWhiteSpace(p.Summary) ? CleanSummary(p.Summary).description : "Skip the confirmation prompt.", "output" => !string.IsNullOrWhiteSpace(p.Summary) ? CleanSummary(p.Summary).description : "Control output format.", _ => CleanSummary(p.Summary).description }; @@ -407,14 +409,23 @@ private static void AppendBehaviorParams(StringBuilder sb, List _ = sb.AppendLine(); } - private static void AppendDefaultCommand(StringBuilder sb, CliDefaultSchema defaultCmd, CliNamespaceSchema ns, string[]? fullPath, string? binaryName, string[]? reservedMetaCommands) + private static void AppendDefaultCommand( + StringBuilder sb, + CliDefaultSchema defaultCmd, + CliNamespaceSchema ns, + string[]? fullPath, + string? binaryName, + string[]? reservedMetaCommands + ) { _ = sb.AppendLine("## Running without a subcommand"); _ = sb.AppendLine(); // If Kind matches a named command, emit an alias note instead of duplicating parameters - if (!string.IsNullOrWhiteSpace(defaultCmd.Kind) && - (ns.Commands ?? []).Any(c => c.Name.Equals(defaultCmd.Kind, StringComparison.OrdinalIgnoreCase))) + if ( + !string.IsNullOrWhiteSpace(defaultCmd.Kind) && + (ns.Commands ?? []).Any(c => c.Name.Equals(defaultCmd.Kind, StringComparison.OrdinalIgnoreCase)) + ) { _ = sb.AppendLine($"> Running without a subcommand is an alias for [{defaultCmd.Kind}](./{CommandPath(defaultCmd.Kind)}.md)."); _ = sb.AppendLine(); @@ -433,9 +444,7 @@ private static void AppendDefaultCommand(StringBuilder sb, CliDefaultSchema defa if (fullPath is { Length: > 0 }) usageParts.AddRange(fullPath); - var rawUsage = !string.IsNullOrWhiteSpace(defaultCmd.Usage) - ? defaultCmd.Usage - : string.Join(" ", usageParts) + " [options]"; + var rawUsage = !string.IsNullOrWhiteSpace(defaultCmd.Usage) ? defaultCmd.Usage : string.Join(" ", usageParts) + " [options]"; var usageLine = CleanUsage(rawUsage, reservedMetaCommands); _ = sb.AppendLine("```bash"); @@ -477,7 +486,6 @@ private static void AppendDefaultCommand(StringBuilder sb, CliDefaultSchema defa _ = sb.AppendLine(); } } - } private static string CleanUsage(string usage, string[]? reservedMetaCommands) @@ -489,8 +497,7 @@ private static string CleanUsage(string usage, string[]? reservedMetaCommands) return usage.Trim(); } - private static string CommandPath(string name) => - name.Equals("index", StringComparison.OrdinalIgnoreCase) ? $"cmd-{name}" : name; + private static string CommandPath(string name) => name.Equals("index", StringComparison.OrdinalIgnoreCase) ? $"cmd-{name}" : name; private static void AppendPageCard(StringBuilder sb, string title, string url, string? summary) { @@ -502,10 +509,7 @@ private static void AppendPageCard(StringBuilder sb, string title, string url, s _ = sb.AppendLine(); } - private static void AppendParameters( - StringBuilder sb, - IEnumerable parameters, - Dictionary? overrides) + private static void AppendParameters(StringBuilder sb, IEnumerable parameters, Dictionary? overrides) { var filtered = parameters.Where(p => p.Name != "_" && !p.Hidden).ToList(); var positionals = filtered.Where(p => p.Role == "positional"); @@ -554,9 +558,7 @@ private static void AppendParameters( _ = sb.AppendLine($" {string.Join(". ", parts)}."); } - var values = p.EnumValues is { Length: > 0 } - ? string.Join(", ", p.EnumValues) - : legacyValues; + var values = p.EnumValues is { Length: > 0 } ? string.Join(", ", p.EnumValues) : legacyValues; if (!string.IsNullOrWhiteSpace(values)) { @@ -564,9 +566,8 @@ private static void AppendParameters( _ = sb.AppendLine($" **Values:** {values.Trim()}"); } - var defaultValue = (!string.IsNullOrWhiteSpace(p.DefaultValue) && !p.DefaultValue.Equals("default", StringComparison.OrdinalIgnoreCase)) - ? p.DefaultValue - : legacySummaryDefault; + var defaultValue = (!string.IsNullOrWhiteSpace(p.DefaultValue) && + !p.DefaultValue.Equals("default", StringComparison.OrdinalIgnoreCase)) ? p.DefaultValue : legacySummaryDefault; if (!string.IsNullOrWhiteSpace(defaultValue)) { _ = sb.AppendLine(); @@ -608,16 +609,12 @@ private static string FormatConstraints(List? validations) "existing" => "must exist", "rejectsymboliclinks" => "symbolic links not allowed", "expanduserprofile" => "supports `~` home expansion", - "urischeme" when v.Values is { Length: > 0 } => - $"must be a {string.Join(" or ", v.Values)} URI", - "range" when v.Min is not null && v.Max is not null => - $"between {v.Min} and {v.Max}", + "urischeme" when v.Values is { Length: > 0 } => $"must be a {string.Join(" or ", v.Values)} URI", + "range" when v.Min is not null && v.Max is not null => $"between {v.Min} and {v.Max}", "range" when v.Min is not null => $"minimum {v.Min}", "range" when v.Max is not null => $"maximum {v.Max}", - "timespanrange" when v.Min is not null && v.Max is not null => - $"duration between {v.Min} and {v.Max}", - "fileextensions" when v.Values is { Length: > 0 } => - $"extensions: {string.Join(", ", v.Values)}", + "timespanrange" when v.Min is not null && v.Max is not null => $"duration between {v.Min} and {v.Max}", + "fileextensions" when v.Values is { Length: > 0 } => $"extensions: {string.Join(", ", v.Values)}", "pattern" when v.Pattern is not null => $"must match `{v.Pattern}`", _ => null }; @@ -689,11 +686,8 @@ private static (string description, string values, string defaultValue) CleanSum if (defIdx < 0) return (EscapeSubstitutions(normalized), string.Empty, string.Empty); - return ( - EscapeSubstitutions(normalized[..defIdx].Trim()), - string.Empty, - normalized[(defIdx + defaultSep.Length)..].Trim().TrimEnd('.') - ); + return (EscapeSubstitutions(normalized[..defIdx].Trim()), string.Empty, normalized[(defIdx + + defaultSep.Length)..].Trim().TrimEnd('.')); } var description = normalized[..valuesIdx].Trim(); @@ -719,9 +713,9 @@ private static string EscapeSubstitutions(string? text) } private static bool IsBoolFlag(string type) => - type.Equals("boolean", StringComparison.OrdinalIgnoreCase) || - type.StartsWith("Primitive:bool", StringComparison.OrdinalIgnoreCase) || - type.Equals("Primitive", StringComparison.OrdinalIgnoreCase); + type.Equals("boolean", StringComparison.OrdinalIgnoreCase) + || type.StartsWith("Primitive:bool", StringComparison.OrdinalIgnoreCase) + || type.Equals("Primitive", StringComparison.OrdinalIgnoreCase); private static string FormatTypeHint(CliParamSchema p) { @@ -734,12 +728,13 @@ private static string FormatTypeHint(CliParamSchema p) "number" => "number", "boolean" => string.Empty, "enum" => "enum", - "array" => p.ElementType switch - { - "enum" => "enum[]", - "integer" => "int[]", - _ => "string[]" - }, + "array" => + p.ElementType switch + { + "enum" => "enum[]", + "integer" => "int[]", + _ => "string[]" + }, _ => FormatKindV1(type) }; } @@ -756,12 +751,13 @@ private static string FormatKindV1(string kind) "Collection" => "string[]", "Collection" or "Collection" => "int[]", "Enum" => right.Contains('.') ? right[(right.LastIndexOf('.') + 1)..] : right, - "Primitive" => right switch - { - "string" or "string?" => "string", - "int" or "int?" or "Int32" or "Int32?" => "int", - _ => string.Empty - }, + "Primitive" => + right switch + { + "string" or "string?" => "string", + "int" or "int?" or "Int32" or "Int32?" => "int", + _ => string.Empty + }, "FileInfo" => "path", "DirectoryInfo" => "path", _ when left.StartsWith("Collection<") => left["Collection<".Length..].TrimEnd('>') + "[]", @@ -789,9 +785,11 @@ private static string FormatUsage(string usage) while (i < tokens.Length) { var token = tokens[i]; - if ((token.StartsWith("--") || (token.StartsWith('-') && token.Length == 2)) + if ( + (token.StartsWith("--") || (token.StartsWith('-') && token.Length == 2)) && i + 1 < tokens.Length - && (tokens[i + 1].StartsWith('<') || tokens[i + 1].StartsWith("[<"))) + && (tokens[i + 1].StartsWith('<') || tokens[i + 1].StartsWith("[<")) + ) { groups.Add(token + " " + tokens[i + 1]); i += 2; @@ -822,8 +820,7 @@ private static List NamespaceAliases(string[]? fullPath, List string.Join(" ", s.To).Equals(heading, StringComparison.OrdinalIgnoreCase)) + return shortcuts.Where(s => string.Join(" ", s.To).Equals(heading, StringComparison.OrdinalIgnoreCase)) .Select(s => s.From) .ToList(); } @@ -843,7 +840,8 @@ private static IEnumerable AliasUsages( string[]? fullPath, string? binaryName, string canonicalUsage, - List? shortcuts) + List? shortcuts + ) { if (shortcuts is not { Count: > 0 } || fullPath is not { Length: > 1 }) yield break; @@ -867,15 +865,11 @@ private static IEnumerable AliasUsages( continue; // Build alias usage by replacing the canonical prefix in the original usage line - var canonicalPrefix = string.Join(" ", binaryName is not null - ? [binaryName, .. to] - : to); + var canonicalPrefix = string.Join(" ", binaryName is not null ? [binaryName, .. to] : to); if (!canonicalUsage.StartsWith(canonicalPrefix, StringComparison.OrdinalIgnoreCase)) continue; // can't safely rewrite; skip this alias variant var suffix = canonicalUsage[canonicalPrefix.Length..]; - var aliasBase = binaryName is not null - ? $"{binaryName} {shortcut.From}" - : shortcut.From; + var aliasBase = binaryName is not null ? $"{binaryName} {shortcut.From}" : shortcut.From; yield return aliasBase + suffix; } } diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliNamespaceFile.cs b/src/Elastic.Markdown/Extensions/CliReference/CliNamespaceFile.cs index a76b75db62..b1df00e0cc 100644 --- a/src/Elastic.Markdown/Extensions/CliReference/CliNamespaceFile.cs +++ b/src/Elastic.Markdown/Extensions/CliReference/CliNamespaceFile.cs @@ -65,8 +65,15 @@ private string BuildMarkdown() ? _supplementalDoc.FileSystem.File.ReadAllText(_supplementalDoc.FullName) : null; var supplemental = CliSupplementalDoc.Parse(rawSupplemental); - var body = CliMarkdownGenerator.NamespacePage(_namespace, supplemental, _fullPath, _binaryName, _reservedMetaCommands, - error => Collector.EmitError(_supplementalDoc ?? SourceFile, error), _shortcuts); + var body = CliMarkdownGenerator.NamespacePage( + _namespace, + supplemental, + _fullPath, + _binaryName, + _reservedMetaCommands, + error => Collector.EmitError(_supplementalDoc ?? SourceFile, error), + _shortcuts + ); // Prepend supplemental front matter so applies_to (or any other field) in ns-*.md overrides the fallback return supplemental?.FrontMatter is { } fm ? $"{fm}\n\n{body}" : body; } diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs b/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs index d26e584d9a..61214c0b0f 100644 --- a/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs +++ b/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs @@ -17,6 +17,7 @@ namespace Elastic.Markdown.Extensions.CliReference; internal sealed record CliEntityInfo( CliSchema Schema, object Entity, // CliSchema | CliNamespaceSchema | CliCommandSchema | CliShortcutSchema + IFileInfo? SupplementalDoc, /// The clean synthetic file (no cmd- prefix) — used as the MarkdownFile source for correct URL generation. IFileInfo? CleanSyntheticFile = null, @@ -117,15 +118,66 @@ private void EnsureSyntheticFilesBuilt() return null; } - private MarkdownFile? CreateCliFileFromInfo(IFileInfo sourceFile, MarkdownParser markdownParser, CliEntityInfo info) => - info.Entity switch - { - CliSchema schema => new CliRootFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, schema, info.SupplementalDoc, info.Title, info.NavigationTitle, info.AppliesTo), - CliNamespaceSchema ns => new CliNamespaceFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, ns, info.SupplementalDoc, info.FullPath ?? [ns.Segment], info.Schema.Name, info.Schema.ReservedMetaCommands, info.Schema.Shortcuts, info.AppliesTo), - CliCommandSchema cmd => new CliCommandFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, cmd, info.SupplementalDoc, info.FullPath ?? [cmd.Name], info.Schema.Name, info.Schema.ReservedMetaCommands, info.AncestorNamespaceOptions, info.Schema.GlobalOptions, info.Schema.Shortcuts, info.AppliesTo), - CliShortcutSchema shortcut => new CliAliasFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, shortcut, info.Schema.Name, info.AliasCanonicalRelativePath ?? "../"), - _ => null - }; + private MarkdownFile? CreateCliFileFromInfo( + IFileInfo sourceFile, + MarkdownParser markdownParser, + CliEntityInfo info + ) => info.Entity switch + { + CliSchema schema => + new CliRootFile( + sourceFile, + Build.DocumentationSourceDirectory, + markdownParser, + Build, + schema, + info.SupplementalDoc, + info.Title, + info.NavigationTitle, + info.AppliesTo + ), + CliNamespaceSchema ns => + new CliNamespaceFile( + sourceFile, + Build.DocumentationSourceDirectory, + markdownParser, + Build, + ns, + info.SupplementalDoc, + info.FullPath ?? [ns.Segment], + info.Schema.Name, + info.Schema.ReservedMetaCommands, + info.Schema.Shortcuts, + info.AppliesTo + ), + CliCommandSchema cmd => + new CliCommandFile( + sourceFile, + Build.DocumentationSourceDirectory, + markdownParser, + Build, + cmd, + info.SupplementalDoc, + info.FullPath ?? [cmd.Name], + info.Schema.Name, + info.Schema.ReservedMetaCommands, + info.AncestorNamespaceOptions, + info.Schema.GlobalOptions, + info.Schema.Shortcuts, + info.AppliesTo + ), + CliShortcutSchema shortcut => + new CliAliasFile( + sourceFile, + Build.DocumentationSourceDirectory, + markdownParser, + Build, + shortcut, + info.Schema.Name, + info.AliasCanonicalRelativePath ?? "../" + ), + _ => null + }; public void VisitNavigation(INavigationItem navigation, IDocumentationFile model) { } @@ -135,7 +187,9 @@ public bool TryGetDocumentationFileBySlug(DocumentationSet documentationSet, str return false; } - public IReadOnlyCollection<(IFileInfo, DocumentationFile)> ScanDocumentationFiles(Func defaultFileHandling) + public IReadOnlyCollection<(IFileInfo, DocumentationFile)> ScanDocumentationFiles( + Func defaultFileHandling + ) { EnsureSyntheticFilesBuilt(); if (_syntheticFileInfos is not { Count: > 0 }) @@ -170,8 +224,9 @@ private List BuildSyntheticFiles() foreach (var cliRef in cliRefs) { - var schemaFileInfo = Build.ReadFileSystem.FileInfo.New( - Build.ReadFileSystem.Path.Join(Build.DocumentationSourceDirectory.FullName, cliRef.SchemaPath)); + var schemaFileInfo = Build.ReadFileSystem + .FileInfo + .New(Build.ReadFileSystem.Path.Join(Build.DocumentationSourceDirectory.FullName, cliRef.SchemaPath)); if (!schemaFileInfo.Exists) continue; @@ -200,7 +255,15 @@ private List BuildSyntheticFiles() var rootSupplemental = FindSupplemental(supplementalDirPath, [], isNamespace: true, matched); var rootSyntheticPath = SyntheticPath(Build.DocumentationSourceDirectory.FullName, virtualRoot, [], isNamespace: true); var rootFileInfo = Build.ReadFileSystem.FileInfo.New(rootSyntheticPath); - var rootInfo = new CliEntityInfo(schema, schema, rootSupplemental, rootFileInfo, Title: cliRef.Title, NavigationTitle: cliRef.NavigationTitle, AppliesTo: appliesTo); + var rootInfo = new CliEntityInfo( + schema, + schema, + rootSupplemental, + rootFileInfo, + Title: cliRef.Title, + NavigationTitle: cliRef.NavigationTitle, + AppliesTo: appliesTo + ); _syntheticFiles![rootSyntheticPath] = rootInfo; if (rootSupplemental != null) _supplementalFiles![rootSupplemental.FullName] = rootInfo; @@ -220,7 +283,17 @@ private List BuildSyntheticFiles() } // Namespaces (recursive) - CollectNamespaceFiles(Build.DocumentationSourceDirectory.FullName, virtualRoot, supplementalDirPath, schema.Namespaces, [], matched, fileInfos, schema, appliesTo: appliesTo); + CollectNamespaceFiles( + Build.DocumentationSourceDirectory.FullName, + virtualRoot, + supplementalDirPath, + schema.Namespaces, + [], + matched, + fileInfos, + schema, + appliesTo: appliesTo + ); // Shortcut alias pages foreach (var shortcut in schema.Shortcuts ?? []) @@ -228,8 +301,10 @@ private List BuildSyntheticFiles() var aliasPath = SyntheticPath(Build.DocumentationSourceDirectory.FullName, virtualRoot, [shortcut.From], isNamespace: true); if (_syntheticFiles!.ContainsKey(aliasPath)) { - Build.Collector.EmitError(schemaFileInfo, - $"CLI shortcut '{shortcut.From}' conflicts with an existing path; skipping alias."); + Build.Collector.EmitError( + schemaFileInfo, + $"CLI shortcut '{shortcut.From}' conflicts with an existing path; skipping alias." + ); continue; } var aliasFileInfo = Build.ReadFileSystem.FileInfo.New(aliasPath); @@ -257,7 +332,8 @@ private void CollectNamespaceFiles( List fileInfos, CliSchema schema, IReadOnlyList<(string Segment, List? Options)>? ancestorOptions = null, - ApplicableTo? appliesTo = null) + ApplicableTo? appliesTo = null + ) { foreach (var ns in namespaces) { @@ -283,14 +359,33 @@ private void CollectNamespaceFiles( var cmdPath = SyntheticPath(docSourceDir, virtualRoot, cmdSegments, isNamespace: false); var cmdFileInfo = Build.ReadFileSystem.FileInfo.New(cmdPath); var cmdSupplemental = FindSupplemental(supplementalDirPath, cmdSegments, isNamespace: false, matched); - var cmdInfo = new CliEntityInfo(schema, cmd, cmdSupplemental, cmdFileInfo, FullPath: cmdSegments, AncestorNamespaceOptions: cmdAncestors, AppliesTo: appliesTo); + var cmdInfo = new CliEntityInfo( + schema, + cmd, + cmdSupplemental, + cmdFileInfo, + FullPath: cmdSegments, + AncestorNamespaceOptions: cmdAncestors, + AppliesTo: appliesTo + ); _syntheticFiles[cmdPath] = cmdInfo; if (cmdSupplemental != null) _supplementalFiles![cmdSupplemental.FullName] = cmdInfo; fileInfos.Add(cmdFileInfo); } - CollectNamespaceFiles(docSourceDir, virtualRoot, supplementalDirPath, ns.Namespaces ?? [], fullNsPath, matched, fileInfos, schema, cmdAncestors, appliesTo); + CollectNamespaceFiles( + docSourceDir, + virtualRoot, + supplementalDirPath, + ns.Namespaces ?? [], + fullNsPath, + matched, + fileInfos, + schema, + cmdAncestors, + appliesTo + ); } } @@ -310,9 +405,7 @@ internal static string SyntheticPath(string docSourceDir, string virtualRoot, st { // Commands use clean name (no cmd- prefix) for URL e.g. /cli/assembler/deploy/apply. // Exception: commands named "index" must keep cmd- prefix to avoid collision with namespace index.md pages. - var name = segments[^1].Equals("index", StringComparison.OrdinalIgnoreCase) - ? $"cmd-{segments[^1]}" - : segments[^1]; + var name = segments[^1].Equals("index", StringComparison.OrdinalIgnoreCase) ? $"cmd-{segments[^1]}" : segments[^1]; var parentSegments = segments.Length > 1 ? segments[..^1] : []; var parentPath = parentSegments.Length > 0 ? Path.Combine([.. parentSegments]) : string.Empty; return string.IsNullOrEmpty(parentPath) @@ -379,8 +472,7 @@ private static IEnumerable FlatPrefixCandidates(string[] segments, bool private void ValidateSupplementalFiles(string supplementalDirPath, HashSet matched, string context) { - foreach (var file in Build.ReadFileSystem.Directory - .EnumerateFiles(supplementalDirPath, "*.md", SearchOption.AllDirectories)) + foreach (var file in Build.ReadFileSystem.Directory.EnumerateFiles(supplementalDirPath, "*.md", SearchOption.AllDirectories)) { var name = Path.GetFileName(file); var relPath = Path.GetRelativePath(supplementalDirPath, file); @@ -388,12 +480,14 @@ private void ValidateSupplementalFiles(string supplementalDirPath, HashSet - (string)table[key]; + private static string GetString(TomlTable table, string key) => (string)table[key]; private static string[]? TryGetStringArray(TomlTable table, string key) => table.TryGetValue(key, out var node) && node is TomlArray t ? t.OfType().ToArray() : null; - private static string? TryGetString(TomlTable table, string key) => - table.TryGetValue(key, out var node) && node is string s ? s : null; + private static string? TryGetString(TomlTable table, string key) => table.TryGetValue(key, out var node) && node is string s ? s : null; - private static int? TryGetInt(TomlTable table, string key) => - table.TryGetValue(key, out var node) && node is long l ? (int)l : null; + private static int? TryGetInt(TomlTable table, string key) => table.TryGetValue(key, out var node) && node is long l ? (int)l : null; } diff --git a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRuleFile.cs b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRuleFile.cs index f3a7c1b07b..4754f593f1 100644 --- a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRuleFile.cs +++ b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRuleFile.cs @@ -13,10 +13,12 @@ namespace Elastic.Markdown.Extensions.DetectionRules; public record DeprecatedDetectionRuleOverviewFile : MarkdownFile { - public DeprecatedDetectionRuleOverviewFile(IFileInfo sourceFile, IDirectoryInfo rootPath, MarkdownParser parser, BuildContext build) - : base(sourceFile, rootPath, parser, build) - { - } + public DeprecatedDetectionRuleOverviewFile( + IFileInfo sourceFile, + IDirectoryInfo rootPath, + MarkdownParser parser, + BuildContext build + ) : base(sourceFile, rootPath, parser, build) { } internal ILeafNavigationItem[] RuleNavigations { get; set; } = []; @@ -57,10 +59,7 @@ private string GetMarkdown() var markdown = intro + "\n\n"; - var groupedRules = rules - .GroupBy(r => r.Model.Rule.Domain ?? "Unspecified") - .OrderBy(g => g.Key) - .ToArray(); + var groupedRules = rules.GroupBy(r => r.Model.Rule.Domain ?? "Unspecified").OrderBy(g => g.Key).ToArray(); foreach (var group in groupedRules) { @@ -75,10 +74,13 @@ private string GetMarkdown() public record DetectionRuleOverviewFile : MarkdownFile { - public DetectionRuleOverviewFile(IFileInfo sourceFile, IDirectoryInfo rootPath, MarkdownParser parser, BuildContext build) - : base(sourceFile, rootPath, parser, build) - { - } + public DetectionRuleOverviewFile(IFileInfo sourceFile, IDirectoryInfo rootPath, MarkdownParser parser, BuildContext build) : base( + sourceFile, + rootPath, + parser, + build + ) + { } internal ILeafNavigationItem[] RuleNavigations { get; set; } = []; @@ -100,14 +102,10 @@ protected override Task GetParseDocumentAsync(Cancel ctx) private string GetMarkdown() { var rules = RuleNavigations.Select(navigation => (Navigation: navigation, Model: (DetectionRuleFile)navigation.Model)).ToList(); - var groupedRules = - rules - .GroupBy(r => r.Model.Rule.Domain ?? "Unspecified") - .OrderBy(g => g.Key) - .ToArray(); + var groupedRules = rules.GroupBy(r => r.Model.Rule.Domain ?? "Unspecified").OrderBy(g => g.Key).ToArray(); // language=markdown var markdown = -""" + """ # Prebuilt detection rules reference :::{important} @@ -119,7 +117,7 @@ private string GetMarkdown() foreach (var group in groupedRules) { markdown += -$""" + $""" ## {group.Key} @@ -128,18 +126,14 @@ private string GetMarkdown() { // TODO update this to use the new URL from navigation markdown += -$""" + $""" [{model.Rule.Name}](!{navigation.Url})
"""; - } - } - return markdown; } - } public record DetectionRuleFile : MarkdownFile @@ -150,12 +144,12 @@ public record DetectionRuleFile : MarkdownFile public IFileInfo RuleSourceMarkdownPath { get; } - public DetectionRuleFile( - IFileInfo sourceFile, - IDirectoryInfo rootPath, - MarkdownParser parser, - BuildContext build - ) : base(sourceFile, rootPath, parser, build) + public DetectionRuleFile(IFileInfo sourceFile, IDirectoryInfo rootPath, MarkdownParser parser, BuildContext build) : base( + sourceFile, + rootPath, + parser, + build + ) { RuleSourceMarkdownPath = GetRuleSourcePath(sourceFile, build); LinkReferenceRelativePath = Path.GetRelativePath(build.DocumentationSourceDirectory.FullName, RuleSourceMarkdownPath.FullName); @@ -210,7 +204,7 @@ private string GetMarkdown() // language=markdown var markdown = -$""" + $""" # {Rule.Name} {deprecationNotice}{Rule.Description} @@ -236,7 +230,7 @@ private string GetMarkdown() if (!string.IsNullOrWhiteSpace(Rule.Setup)) { markdown += -$""" + $""" {Rule.Setup} """; @@ -246,7 +240,7 @@ private string GetMarkdown() if (!string.IsNullOrWhiteSpace(Rule.Note)) { markdown += -$""" + $""" ## Investigation guide @@ -257,7 +251,7 @@ private string GetMarkdown() if (!string.IsNullOrWhiteSpace(Rule.Query)) { markdown += -$""" + $""" ## Rule Query @@ -271,7 +265,7 @@ private string GetMarkdown() { // language=markdown markdown += -$""" + $""" **Framework:** {threat.Framework} @@ -293,7 +287,7 @@ private string GetMarkdown() } private static string TechniqueMarkdown(DetectionRuleSubTechnique technique, string header) => -$""" + $""" * {header}: * Name: {technique.Name} diff --git a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRulesDocsBuilderExtension.cs b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRulesDocsBuilderExtension.cs index 0c7424540b..ce935a869d 100644 --- a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRulesDocsBuilderExtension.cs +++ b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRulesDocsBuilderExtension.cs @@ -19,18 +19,19 @@ public class DetectionRulesDocsBuilderExtension(BuildContext build) : IDocsBuild private BuildContext Build => build; private bool _versionLockInitialized; - private IReadOnlySet DeprecatedOverviewFileNames { get; } = - GetAllDetectionRuleOverviewRefs(build.ConfigurationYaml.TableOfContents) - .Select(r => r.DeprecatedFile ?? "deprecated-detection-rules.md") - .ToHashSet(); + private IReadOnlySet DeprecatedOverviewFileNames { get; } = GetAllDetectionRuleOverviewRefs( + build.ConfigurationYaml.TableOfContents + ).Select(r => r.DeprecatedFile ?? "deprecated-detection-rules.md").ToHashSet(); public IEnumerable ExternalScopeRoots => - GetAllDetectionRuleOverviewRefs(Build.ConfigurationYaml.TableOfContents) - .SelectMany(r => r.DetectionRuleFolders) - .Select(f => Path.GetFullPath(f, Build.DocumentationSourceDirectory.FullName)) - .Distinct(); + GetAllDetectionRuleOverviewRefs(Build.ConfigurationYaml.TableOfContents).SelectMany(r => r.DetectionRuleFolders).Select( + f => Path.GetFullPath(f, Build.DocumentationSourceDirectory.FullName) + ).Distinct(); - public IDocumentationFileExporter? FileExporter { get; } = new RuleDocumentationFileExporter(build.ReadFileSystem, build.WriteFileSystem); + public IDocumentationFileExporter? FileExporter { get; } = new RuleDocumentationFileExporter( + build.ReadFileSystem, + build.WriteFileSystem + ); public DocumentationFile? CreateDocumentationFile(IFileInfo file, MarkdownParser markdownParser) { @@ -90,19 +91,17 @@ public bool TryGetDocumentationFileBySlug(DocumentationSet documentationSet, str return documentationSet.Files.TryGetValue(filePath, out documentationFile); } - public IReadOnlyCollection<(IFileInfo, DocumentationFile)> ScanDocumentationFiles(Func defaultFileHandling) + public IReadOnlyCollection<(IFileInfo, DocumentationFile)> ScanDocumentationFiles( + Func defaultFileHandling + ) { var overviewRefs = GetAllDetectionRuleOverviewRefs(Build.ConfigurationYaml.TableOfContents).ToArray(); // Pass each overviewRef as a single-element sequence so the switch case that // checks DeprecatedSiblingRef is triggered, picking up both active and deprecated rules. - var rules = overviewRefs - .SelectMany(r => GetAllDetectionRuleRefs([r])) - .ToArray(); + var rules = overviewRefs.SelectMany(r => GetAllDetectionRuleRefs([r])).ToArray(); - var result = rules - .Select(r => (r.FileInfo, defaultFileHandling(r.FileInfo, r.FileInfo.Directory!))) - .ToList(); + var result = rules.Select(r => (r.FileInfo, defaultFileHandling(r.FileInfo, r.FileInfo.Directory!))).ToList(); // Pre-register synthetic deprecated overview files for overviews without a physical file. // When the physical file exists it is already picked up by the normal source directory scan. @@ -134,23 +133,29 @@ public bool TryGetDocumentationFileBySlug(DocumentationSet documentationSet, str // Finds all DetectionRuleOverviewRef instances at any depth in the TOC tree private static IEnumerable GetAllDetectionRuleOverviewRefs(IEnumerable items) => - items.SelectMany(item => item switch - { - DetectionRuleOverviewRef r => [r], - FileRef fr => GetAllDetectionRuleOverviewRefs(fr.Children), - _ => [] - }); + items.SelectMany( + item => + item switch + { + DetectionRuleOverviewRef r => [r], + FileRef fr => GetAllDetectionRuleOverviewRefs(fr.Children), + _ => [] + } + ); // Finds all DetectionRuleRef instances at any depth within a set of TOC items. // Also scans DeprecatedSiblingRef on DetectionRuleOverviewRef since deprecated rules // are no longer nested in Children — they live in the sibling's Children instead. private static IEnumerable GetAllDetectionRuleRefs(IEnumerable items) => - items.SelectMany(item => item switch - { - DetectionRuleRef dr => [dr], - DetectionRuleOverviewRef r when r.DeprecatedSiblingRef is { } dep => - GetAllDetectionRuleRefs(r.Children).Concat(GetAllDetectionRuleRefs(dep.Children)), - FileRef fr => GetAllDetectionRuleRefs(fr.Children), - _ => [] - }); + items.SelectMany( + item => + item switch + { + DetectionRuleRef dr => [dr], + DetectionRuleOverviewRef r when r.DeprecatedSiblingRef is { } dep => + GetAllDetectionRuleRefs(r.Children).Concat(GetAllDetectionRuleRefs(dep.Children)), + FileRef fr => GetAllDetectionRuleRefs(fr.Children), + _ => [] + } + ); } diff --git a/src/Elastic.Markdown/Extensions/DetectionRules/RuleDocumentationFileExporter.cs b/src/Elastic.Markdown/Extensions/DetectionRules/RuleDocumentationFileExporter.cs index 496970daeb..92ec8d224a 100644 --- a/src/Elastic.Markdown/Extensions/DetectionRules/RuleDocumentationFileExporter.cs +++ b/src/Elastic.Markdown/Extensions/DetectionRules/RuleDocumentationFileExporter.cs @@ -8,8 +8,10 @@ namespace Elastic.Markdown.Extensions.DetectionRules; -public class RuleDocumentationFileExporter(IFileSystem readFileSystem, IFileSystem writeFileSystem) - : DocumentationFileExporterBase(readFileSystem, writeFileSystem) +public class RuleDocumentationFileExporter(IFileSystem readFileSystem, IFileSystem writeFileSystem) : DocumentationFileExporterBase( + readFileSystem, + writeFileSystem +) { public override string Name { get; } = nameof(RuleDocumentationFileExporter); @@ -21,7 +23,13 @@ public override async ValueTask ProcessFile(ProcessingFileContext context, Cance switch (context.File) { case DetectionRuleFile df: - context.MarkdownDocument = await htmlWriter.WriteAsync(DetectionRuleFile.OutputPath(outputFile, context.BuildContext), df, conversionCollector, ctx); + context.MarkdownDocument = + await htmlWriter.WriteAsync( + DetectionRuleFile.OutputPath(outputFile, context.BuildContext), + df, + conversionCollector, + ctx + ); break; case MarkdownFile markdown: context.MarkdownDocument = await htmlWriter.WriteAsync(outputFile, markdown, conversionCollector, ctx); diff --git a/src/Elastic.Markdown/Extensions/IDocsBuilderExtension.cs b/src/Elastic.Markdown/Extensions/IDocsBuilderExtension.cs index a1d5af396d..d2ae74bf07 100644 --- a/src/Elastic.Markdown/Extensions/IDocsBuilderExtension.cs +++ b/src/Elastic.Markdown/Extensions/IDocsBuilderExtension.cs @@ -29,7 +29,9 @@ public interface IDocsBuilderExtension bool TryGetDocumentationFileBySlug(DocumentationSet documentationSet, string slug, out DocumentationFile? documentationFile); /// Allows the extension to discover more documentation files for - IReadOnlyCollection<(IFileInfo, DocumentationFile)> ScanDocumentationFiles(Func defaultFileHandling); + IReadOnlyCollection<(IFileInfo, DocumentationFile)> ScanDocumentationFiles( + Func defaultFileHandling + ); MarkdownFile? CreateMarkdownFile(IFileInfo file, IDirectoryInfo sourceDirectory, MarkdownParser markdownParser); diff --git a/src/Elastic.Markdown/Extensions/Listing/ListingDocsBuilderExtension.cs b/src/Elastic.Markdown/Extensions/Listing/ListingDocsBuilderExtension.cs index 8d5c3f99d5..5d9071640f 100644 --- a/src/Elastic.Markdown/Extensions/Listing/ListingDocsBuilderExtension.cs +++ b/src/Elastic.Markdown/Extensions/Listing/ListingDocsBuilderExtension.cs @@ -62,9 +62,9 @@ private void CollectListingRefs(IReadOnlyCollection items) private void RegisterListingRef(ListingRef listingRef) { - var rootDir = Build.ReadFileSystem.Path.Join( - Build.DocumentationSourceDirectory.FullName, - listingRef.PathRelativeToDocumentationSet); + var rootDir = Build.ReadFileSystem + .Path + .Join(Build.DocumentationSourceDirectory.FullName, listingRef.PathRelativeToDocumentationSet); // Root index RegisterIndexPath(Build.ReadFileSystem.Path.Join(rootDir, "index.md")); @@ -80,18 +80,21 @@ private void RegisterListingRef(ListingRef listingRef) if (groupIndex is null) { // Synthesize //index.md - var groupIndexPath = Build.ReadFileSystem.Path.Join( - Build.DocumentationSourceDirectory.FullName, - listingRef.PathRelativeToDocumentationSet, - groupRef.GroupKey, - "index.md"); + var groupIndexPath = Build.ReadFileSystem + .Path + .Join( + Build.DocumentationSourceDirectory.FullName, + listingRef.PathRelativeToDocumentationSet, + groupRef.GroupKey, + "index.md" + ); RegisterIndexPath(groupIndexPath); } else { - RegisterIndexPath(Build.ReadFileSystem.Path.Join( - Build.DocumentationSourceDirectory.FullName, - groupIndex.PathRelativeToDocumentationSet)); + RegisterIndexPath( + Build.ReadFileSystem.Path.Join(Build.DocumentationSourceDirectory.FullName, groupIndex.PathRelativeToDocumentationSet) + ); } } } @@ -133,7 +136,8 @@ public bool TryGetDocumentationFileBySlug(DocumentationSet documentationSet, str } public IReadOnlyCollection<(IFileInfo, DocumentationFile)> ScanDocumentationFiles( - Func defaultFileHandling) + Func defaultFileHandling + ) { EnsureInitialized(); if (_syntheticIndexFiles is not { Count: > 0 }) diff --git a/src/Elastic.Markdown/Extensions/Listing/ListingIndexFile.cs b/src/Elastic.Markdown/Extensions/Listing/ListingIndexFile.cs index d8fdbd272b..d91b8be352 100644 --- a/src/Elastic.Markdown/Extensions/Listing/ListingIndexFile.cs +++ b/src/Elastic.Markdown/Extensions/Listing/ListingIndexFile.cs @@ -16,14 +16,13 @@ namespace Elastic.Markdown.Extensions.Listing; ///
public record ListingIndexFile : IO.MarkdownFile { - public ListingIndexFile( - IFileInfo sourceFile, - IDirectoryInfo rootPath, - MarkdownParser parser, - BuildContext build - ) : base(sourceFile, rootPath, parser, build) - { - } + public ListingIndexFile(IFileInfo sourceFile, IDirectoryInfo rootPath, MarkdownParser parser, BuildContext build) : base( + sourceFile, + rootPath, + parser, + build + ) + { } protected override Task GetMinimalParseDocumentAsync(Cancel ctx) { @@ -60,8 +59,6 @@ private static string HumanizeFolderName(string name) { // "detection-rules" → "Detection Rules", "rfcs" → "Rfcs" var words = name.Replace('_', '-').Split('-'); - return string.Join(" ", words.Select(w => w.Length > 0 - ? char.ToUpperInvariant(w[0]) + w[1..] - : w)); + return string.Join(" ", words.Select(w => w.Length > 0 ? char.ToUpperInvariant(w[0]) + w[1..] : w)); } } diff --git a/src/Elastic.Markdown/Helpers/DocumentationObjectPoolProvider.cs b/src/Elastic.Markdown/Helpers/DocumentationObjectPoolProvider.cs index ebaf4a5ceb..be9cb65255 100644 --- a/src/Elastic.Markdown/Helpers/DocumentationObjectPoolProvider.cs +++ b/src/Elastic.Markdown/Helpers/DocumentationObjectPoolProvider.cs @@ -18,19 +18,33 @@ internal static class DocumentationObjectPoolProvider private static readonly ObjectPoolProvider PoolProvider = new DefaultObjectPoolProvider(); public static readonly ObjectPool StringBuilderPool = PoolProvider.CreateStringBuilderPool(256, 4 * 1024); - public static readonly ObjectPool StringWriterPool = PoolProvider.Create(new ReusableStringWriterPooledObjectPolicy()); + public static readonly ObjectPool StringWriterPool = PoolProvider.Create( + new ReusableStringWriterPooledObjectPolicy() + ); public static readonly ObjectPool HtmlRendererPool = PoolProvider.Create(new HtmlRendererPooledObjectPolicy()); - private static readonly ObjectPool LlmMarkdownRendererPool = PoolProvider.Create(new LlmMarkdownRendererPooledObjectPolicy()); - private static readonly ObjectPool PlainTextRendererPool = PoolProvider.Create(new PlainTextRendererPooledObjectPolicy()); - - public static string UseLlmMarkdownRenderer(IDocumentationConfigurationContext buildContext, TContext context, Action action) => - UseLlmMarkdownRenderer(buildContext, linkUrlRewriter: null, context, action); + private static readonly ObjectPool LlmMarkdownRendererPool = PoolProvider.Create( + new LlmMarkdownRendererPooledObjectPolicy() + ); + private static readonly ObjectPool PlainTextRendererPool = PoolProvider.Create( + new PlainTextRendererPooledObjectPolicy() + ); + + public static string UseLlmMarkdownRenderer( + IDocumentationConfigurationContext buildContext, + TContext context, + Action action + ) => UseLlmMarkdownRenderer(buildContext, linkUrlRewriter: null, context, action); /// /// Same as /// but allows overriding link URL resolution (e.g. for the OKF exporter's bundle-relative links). /// - public static string UseLlmMarkdownRenderer(IDocumentationConfigurationContext buildContext, Func? linkUrlRewriter, TContext context, Action action) + public static string UseLlmMarkdownRenderer( + IDocumentationConfigurationContext buildContext, + Func? linkUrlRewriter, + TContext context, + Action action + ) { var subscription = LlmMarkdownRendererPool.Get(); subscription.SetBuildContext(buildContext); @@ -48,7 +62,11 @@ public static string UseLlmMarkdownRenderer(IDocumentationConfiguratio } } - public static string UsePlainTextRenderer(IDocumentationConfigurationContext buildContext, TContext context, Action action) + public static string UsePlainTextRenderer( + IDocumentationConfigurationContext buildContext, + TContext context, + Action action + ) { var subscription = PlainTextRendererPool.Get(); subscription.SetBuildContext(buildContext); @@ -131,10 +149,7 @@ public LlmMarkdownRenderSubscription Create() var stringBuilder = StringBuilderPool.Get(); using var stringWriter = StringWriterPool.Get(); stringWriter.SetStringBuilder(stringBuilder); - var renderer = new LlmMarkdownRenderer(stringWriter) - { - BuildContext = null! - }; + var renderer = new LlmMarkdownRenderer(stringWriter) { BuildContext = null! }; return new LlmMarkdownRenderSubscription { LlmMarkdownRenderer = renderer, RentedStringBuilder = stringBuilder }; } @@ -178,10 +193,7 @@ public PlainTextRenderSubscription Create() var stringBuilder = StringBuilderPool.Get(); using var stringWriter = StringWriterPool.Get(); stringWriter.SetStringBuilder(stringBuilder); - var renderer = new PlainTextRenderer(stringWriter) - { - BuildContext = null! - }; + var renderer = new PlainTextRenderer(stringWriter) { BuildContext = null! }; return new PlainTextRenderSubscription { PlainTextRenderer = renderer, RentedStringBuilder = stringBuilder }; } diff --git a/src/Elastic.Markdown/Helpers/Interpolation.cs b/src/Elastic.Markdown/Helpers/Interpolation.cs index 52ba262d1f..688c6685b4 100644 --- a/src/Elastic.Markdown/Helpers/Interpolation.cs +++ b/src/Elastic.Markdown/Helpers/Interpolation.cs @@ -18,14 +18,14 @@ internal static partial class InterpolationRegex public static class Interpolation { - public static string ReplaceSubstitutions( - this string input, - ParserContext context - ) + public static string ReplaceSubstitutions(this string input, ParserContext context) { var span = input.AsSpan(); - return span.ReplaceSubstitutions([context.Substitutions, context.ContextSubstitutions], context.Build.Collector, out var replacement) - ? replacement : input; + return span.ReplaceSubstitutions( + [context.Substitutions, context.ContextSubstitutions], + context.Build.Collector, + out var replacement + ) ? replacement : input; } public static bool ReplaceSubstitutions( @@ -36,8 +36,10 @@ public static bool ReplaceSubstitutions( ) { replacement = null; - return properties is not null && properties.Count != 0 && - span.IndexOf("}}") >= 0 && span.ReplaceSubstitutions([properties], collector, out replacement); + return properties is not null + && properties.Count != 0 + && span.IndexOf("}}") >= 0 + && span.ReplaceSubstitutions([properties], collector, out replacement); } private static bool ReplaceSubstitutions( @@ -54,10 +56,9 @@ private static bool ReplaceSubstitutions( if (properties.Length == 0 || properties.Sum(p => p.Count) == 0) return false; - var lookups = properties - .Select(p => p as Dictionary ?? new Dictionary(p, StringComparer.OrdinalIgnoreCase)) - .Select(d => d.GetAlternateLookup>()) - .ToArray(); + var lookups = properties.Select( + p => p as Dictionary ?? new Dictionary(p, StringComparer.OrdinalIgnoreCase) + ).Select(d => d.GetAlternateLookup>()).ToArray(); var matchSubs = InterpolationRegex.MatchSubstitutions().EnumerateMatches(span); diff --git a/src/Elastic.Markdown/Helpers/SlugExtensions.cs b/src/Elastic.Markdown/Helpers/SlugExtensions.cs index 85816fc169..3dacff658b 100644 --- a/src/Elastic.Markdown/Helpers/SlugExtensions.cs +++ b/src/Elastic.Markdown/Helpers/SlugExtensions.cs @@ -11,5 +11,4 @@ public static class SlugExtensions private static readonly SlugHelper Instance = new(); public static string Slugify(this string? text) => Instance.GenerateSlug(text); - } diff --git a/src/Elastic.Markdown/HtmlWriter.cs b/src/Elastic.Markdown/HtmlWriter.cs index 40cd683c0e..4333d55931 100644 --- a/src/Elastic.Markdown/HtmlWriter.cs +++ b/src/Elastic.Markdown/HtmlWriter.cs @@ -31,15 +31,16 @@ public class HtmlWriter( INavigationHtmlWriter? navigationHtmlWriter = null, ILegacyUrlMapper? legacyUrlMapper = null, IDocumentInferrerService? documentInferrerService = null -) - : IMarkdownStringRenderer +) : IMarkdownStringRenderer { private DocumentationSet DocumentationSet { get; } = documentationSet; - private INavigationHtmlWriter NavigationHtmlWriter { get; } = - navigationHtmlWriter ?? new IsolatedBuildNavigationHtmlWriter(documentationSet.Context, documentationSet.Navigation); + private INavigationHtmlWriter NavigationHtmlWriter { get; } = navigationHtmlWriter ?? + new IsolatedBuildNavigationHtmlWriter(documentationSet.Context, documentationSet.Navigation); - private StaticFileContentHashProvider StaticFileContentHashProvider { get; } = new(new EmbeddedOrPhysicalFileProvider(documentationSet.Context)); + private StaticFileContentHashProvider StaticFileContentHashProvider { get; } = new( + new EmbeddedOrPhysicalFileProvider(documentationSet.Context) + ); private ILegacyUrlMapper LegacyUrlMapper { get; } = legacyUrlMapper ?? new NoopLegacyUrlMapper(); private INavigationTraversable NavigationTraversable { get; } = positionalNavigation ?? documentationSet; @@ -47,8 +48,7 @@ public class HtmlWriter( private IPageViewFactory PageViewFactory { get; } = pageViewFactory ?? new DefaultPageViewFactory(); /// - public string Render(string markdown, IFileInfo? source) => - RenderCore(markdown, source, stripFirstHeadingLevel1: true); + public string Render(string markdown, IFileInfo? source) => RenderCore(markdown, source, stripFirstHeadingLevel1: true); /// public string RenderPreservingFirstHeading(string markdown, IFileInfo? source) => @@ -91,16 +91,17 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc // For hidden nav items (e.g. individual detection rule pages) there is no rendered nav link, // so JS can't mark anything as current. Point it at the nearest visible ancestor instead. // Island pages have their own sidebar nav so JS uses window.location directly — skip the parent lookup. - var navActiveUrl = current.Hidden && current.FindIslandRoot() is null - ? parents.FirstOrDefault(p => !p.Hidden)?.Url - : null; + var navActiveUrl = current.Hidden && current.FindIslandRoot() is null ? parents.FirstOrDefault(p => !p.Hidden)?.Url : null; var gitHubRepo = DocumentationSet.Context.Git.GitHubRepository; var branch = DocumentationSet.Context.Git.Branch; string? editUrl = null; if (gitHubRepo is not null && DocumentationSet.Context.Git != GitCheckoutInformation.Unavailable) { var checkoutDirectory = DocumentationSet.Context.DocumentationCheckoutDirectory; - var relativeSourcePath = Path.GetRelativePath(checkoutDirectory.FullName, DocumentationSet.Context.DocumentationSourceDirectory.FullName); + var relativeSourcePath = Path.GetRelativePath( + checkoutDirectory.FullName, + DocumentationSet.Context.DocumentationSourceDirectory.FullName + ); var path = UrlPath.Join(relativeSourcePath, markdown.RelativePath); editUrl = $"https://github.com/{gitHubRepo}/edit/{branch}/{path}"; } @@ -144,29 +145,29 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc // Get versioning from inference result's product var pageVersioning = inference.Product?.VersioningSystem ?? DocumentationSet.Context.VersionsConfiguration?.GetVersioningSystem(VersioningSystemId.Stack) - ?? throw new InvalidOperationException($"No versioning system available for page '{markdown.RelativePath}'. " + - "Ensure VersionsConfiguration contains a Stack versioning system or the inferred product has a VersioningSystem defined."); + ?? throw new InvalidOperationException( + $"No versioning system available for page '{markdown.RelativePath}'. " + + "Ensure VersionsConfiguration contains a Stack versioning system or the inferred product has a VersioningSystem defined." + ); - var currentBaseVersion = pageVersioning.IsVersionless - ? null - : $"{pageVersioning.Base.Major}.{pageVersioning.Base.Minor}+"; + var currentBaseVersion = pageVersioning.IsVersionless ? null : $"{pageVersioning.Base.Major}.{pageVersioning.Base.Minor}+"; //TODO should we even distinctby var breadcrumbs = parents.Reverse().DistinctBy(p => p.Url).ToArray(); var breadcrumbsList = CreateStructuredBreadcrumbsData(markdown, breadcrumbs); var structuredBreadcrumbsJsonString = JsonSerializer.Serialize(breadcrumbsList, BreadcrumbsContext.Default.BreadcrumbsList); - // Git info for isolated header var gitBranch = DocumentationSet.Context.Git.Branch; var gitRef = DocumentationSet.Context.Git.Ref; string? gitHubDocsUrl = null; - if (gitHubRepo is not null - && !string.IsNullOrEmpty(gitBranch) && gitBranch != "unavailable") + if (gitHubRepo is not null && !string.IsNullOrEmpty(gitBranch) && gitBranch != "unavailable") { var docsCheckoutDir = DocumentationSet.Context.DocumentationCheckoutDirectory; - var relativeDocsPath = Path.GetRelativePath(docsCheckoutDir.FullName, DocumentationSet.Context.DocumentationSourceDirectory.FullName) - .Replace(Path.DirectorySeparatorChar, '/'); + var relativeDocsPath = Path.GetRelativePath( + docsCheckoutDir.FullName, + DocumentationSet.Context.DocumentationSourceDirectory.FullName + ).Replace(Path.DirectorySeparatorChar, '/'); gitHubDocsUrl = $"https://github.com/{gitHubRepo}/tree/{gitBranch}/{relativeDocsPath}"; } @@ -191,7 +192,10 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc AppliesTo = markdown.YamlFrontMatter?.AppliesTo, GithubEditUrl = editUrl, MarkdownUrl = current.Url == "/" ? "/index.md" : current.Url.TrimEnd('/') + ".md", - AllowIndexing = DocumentationSet.Context.AllowIndexing && markdown.YamlFrontMatter?.NoIndex != true && (markdown.CrossLink.Equals("docs-content://index.md", StringComparison.OrdinalIgnoreCase) || markdown is DetectionRuleFile || !current.ExcludeFromIndexing), + AllowIndexing = + DocumentationSet.Context.AllowIndexing && markdown.YamlFrontMatter?.NoIndex != true && + (markdown.CrossLink.Equals("docs-content://index.md", StringComparison.OrdinalIgnoreCase) || + markdown is DetectionRuleFile || !current.ExcludeFromIndexing), CanonicalBaseUrl = DocumentationSet.Context.CanonicalBaseUrl, GoogleTagManager = DocumentationSet.Context.GoogleTagManager, Optimizely = DocumentationSet.Context.Optimizely, @@ -217,11 +221,7 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc Cta = cta }); - return new RenderResult - { - Html = await slice.RenderAsync(cancellationToken: ctx) - }; - + return new RenderResult { Html = await slice.RenderAsync(cancellationToken: ctx) }; } private BreadcrumbsList CreateStructuredBreadcrumbsData(MarkdownFile markdown, INavigationItem[] crumbs) @@ -229,12 +229,17 @@ private BreadcrumbsList CreateStructuredBreadcrumbsData(MarkdownFile markdown, I List breadcrumbItems = []; var position = 1; // Add parents - breadcrumbItems.AddRange(crumbs.Select(parent => new BreadcrumbListItem - { - Position = position++, - Name = parent.NavigationTitle, - Item = new Uri(DocumentationSet.Context.CanonicalBaseUrl ?? new Uri("http://localhost"), parent.Url).ToString() - })); + breadcrumbItems.AddRange( + crumbs.Select( + parent => + new BreadcrumbListItem + { + Position = position++, + Name = parent.NavigationTitle, + Item = new Uri(DocumentationSet.Context.CanonicalBaseUrl ?? new Uri("http://localhost"), parent.Url).ToString() + } + ) + ); // Add current page breadcrumbItems.Add(new BreadcrumbListItem { @@ -242,14 +247,16 @@ private BreadcrumbsList CreateStructuredBreadcrumbsData(MarkdownFile markdown, I Name = markdown.Title ?? markdown.NavigationTitle, Item = null, }); - var breadcrumbsList = new BreadcrumbsList - { - ItemListElement = breadcrumbItems - }; + var breadcrumbsList = new BreadcrumbsList { ItemListElement = breadcrumbItems }; return breadcrumbsList; } - public async Task WriteAsync(IFileInfo outputFile, MarkdownFile markdown, IConversionCollector? collector, Cancel ctx = default) + public async Task WriteAsync( + IFileInfo outputFile, + MarkdownFile markdown, + IConversionCollector? collector, + Cancel ctx = default + ) { if (outputFile.Directory is { Exists: false }) outputFile.Directory.Create(); @@ -266,9 +273,7 @@ public async Task WriteAsync(IFileInfo outputFile, MarkdownFil if (dir is not null && !writeFileSystem.Directory.Exists(dir)) _ = writeFileSystem.Directory.CreateDirectory(dir); - path = dir is null - ? Path.GetFileNameWithoutExtension(outputFile.Name) + ".html" - : Path.Join(dir, "index.html"); + path = dir is null ? Path.GetFileNameWithoutExtension(outputFile.Name) + ".html" : Path.Join(dir, "index.html"); } var document = await markdown.ParseFullAsync(DocumentationSet.TryFindDocumentByRelativePath, ctx); @@ -279,8 +284,6 @@ public async Task WriteAsync(IFileInfo outputFile, MarkdownFil return document; } - - } public record RenderResult diff --git a/src/Elastic.Markdown/IO/DocumentationFile.cs b/src/Elastic.Markdown/IO/DocumentationFile.cs index 7b0ecd50a5..4a14682900 100644 --- a/src/Elastic.Markdown/IO/DocumentationFile.cs +++ b/src/Elastic.Markdown/IO/DocumentationFile.cs @@ -34,14 +34,24 @@ protected DocumentationFile(IFileInfo sourceFile, IDirectoryInfo rootPath, strin public IFileInfo SourceFile { get; } } -public record ImageFile(IFileInfo SourceFile, IDirectoryInfo RootPath, string Repository, string MimeType = "image/png") - : DocumentationFile(SourceFile, RootPath, Repository); +public record ImageFile( + IFileInfo SourceFile, + IDirectoryInfo RootPath, + string Repository, + string MimeType = "image/png" +) : DocumentationFile(SourceFile, RootPath, Repository); -public record ExcludedFile(IFileInfo SourceFile, IDirectoryInfo RootPath, string Repository) - : DocumentationFile(SourceFile, RootPath, Repository); +public record ExcludedFile(IFileInfo SourceFile, IDirectoryInfo RootPath, string Repository) : DocumentationFile( + SourceFile, + RootPath, + Repository +); -public record SnippetFile(IFileInfo SourceFile, IDirectoryInfo RootPath, string Repository) - : DocumentationFile(SourceFile, RootPath, Repository) +public record SnippetFile(IFileInfo SourceFile, IDirectoryInfo RootPath, string Repository) : DocumentationFile( + SourceFile, + RootPath, + Repository +) { private SnippetAnchors? Anchors { get; set; } private bool _parsed; @@ -62,7 +72,15 @@ public record SnippetFile(IFileInfo SourceFile, IDirectoryInfo RootPath, string } var document = parser.MinimalParseAsync(SourceFile, default).GetAwaiter().GetResult(); - var toc = MarkdownFile.GetAnchors(collector, documentationFileLookup, parser, frontMatter, document, new Dictionary(), out var anchors); + var toc = MarkdownFile.GetAnchors( + collector, + documentationFileLookup, + parser, + frontMatter, + document, + new Dictionary(), + out var anchors + ); Anchors = new SnippetAnchors(anchors, toc); _parsed = true; return Anchors; diff --git a/src/Elastic.Markdown/IO/DocumentationSet.cs b/src/Elastic.Markdown/IO/DocumentationSet.cs index 3214f4000b..cc05598ec6 100644 --- a/src/Elastic.Markdown/IO/DocumentationSet.cs +++ b/src/Elastic.Markdown/IO/DocumentationSet.cs @@ -87,7 +87,16 @@ public DocumentationSet( EnabledExtensions = InstantiateExtensions(); var fileFactory = new MarkdownFileFactory(context, MarkdownParser, EnabledExtensions); - Navigation = new DocumentationSetNavigation(context.ConfigurationYaml, context, fileFactory, null, null, context.UrlPathPrefix, CrossLinkResolver); + Navigation = + new DocumentationSetNavigation( + context.ConfigurationYaml, + context, + fileFactory, + null, + null, + context.UrlPathPrefix, + CrossLinkResolver + ); VisitNavigation(Navigation); Name = Context.Git != GitCheckoutInformation.Unavailable @@ -117,12 +126,10 @@ private void ValidateRootIndexExists() if (Context.BuildType != BuildType.Isolated || Configuration.Registry == DocSetRegistry.Public) return; - var indexFile = Context.ReadFileSystem.FileInfo.New( - Path.Join(SourceDirectory.FullName, "index.md")); + var indexFile = Context.ReadFileSystem.FileInfo.New(Path.Join(SourceDirectory.FullName, "index.md")); if (!indexFile.Exists) - Context.EmitError(Configuration.SourceFile, - "Non-public documentation sets require a root index.md file"); + Context.EmitError(Configuration.SourceFile, "Non-public documentation sets require a root index.md file"); } public DocumentationSetNavigation Navigation { get; } @@ -187,7 +194,6 @@ void ValidateExists(string from, string to, IReadOnlyDictionary { Context.EmitError(Configuration.SourceFile, $"Redirect {from} points to {to} which does not exist"); return; - } if (file is not MarkdownFile markdownFile) @@ -200,10 +206,8 @@ void ValidateExists(string from, string to, IReadOnlyDictionary return; markdownFile.AnchorRemapping = - markdownFile.AnchorRemapping? - .Concat(valueAnchors) - .DistinctBy(kv => kv.Key) - .ToDictionary(kv => kv.Key, kv => kv.Value) ?? valueAnchors; + markdownFile.AnchorRemapping?.Concat(valueAnchors).DistinctBy(kv => kv.Key).ToDictionary(kv => kv.Key, kv => kv.Value) ?? + valueAnchors; } } @@ -245,8 +249,11 @@ public async Task ResolveDirectoryTree(Cancel ctx) MaxDegreeOfParallelism = Math.Max(Environment.ProcessorCount * 4, 32), CancellationToken = ctx }; - await Parallel.ForEachAsync(MarkdownFiles, options, - async (file, token) => await file.MinimalParseAsync(TryFindDocumentByRelativePath, token)); + await Parallel.ForEachAsync( + MarkdownFiles, + options, + async (file, token) => await file.MinimalParseAsync(TryFindDocumentByRelativePath, token) + ); _resolved = true; } @@ -256,28 +263,20 @@ public RepositoryLinks CreateLinkReference() var redirects = Configuration.Redirects; var crossLinks = Context.Collector.CrossLinks.ToHashSet().OrderBy(l => l).ToArray(); - var leafs = NavigationIndexedByOrder.Values - .OfType>().ToArray(); - var nodes = NavigationIndexedByOrder.Values - .OfType>() - .ToArray(); - - var markdownInNavigation = - leafs - .Select(m => (Markdown: m.Model, Navigation: (INavigationItem)m)) - .Concat(nodes - .Select(g => (Markdown: (MarkdownFile)g.Index.Model, Navigation: (INavigationItem)g)) - ) + var leafs = NavigationIndexedByOrder.Values.OfType>().ToArray(); + var nodes = NavigationIndexedByOrder.Values.OfType>().ToArray(); + + var markdownInNavigation = leafs.Select(m => (Markdown: m.Model, Navigation: (INavigationItem)m)) + .Concat(nodes.Select(g => (Markdown: (MarkdownFile)g.Index.Model, Navigation: (INavigationItem)g))) .ToList(); - var links = markdownInNavigation - .Select(tuple => - { - var path = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? tuple.Markdown.LinkReferenceRelativePath.Replace('\\', '/') - : tuple.Markdown.LinkReferenceRelativePath; - return (Path: path, tuple.Markdown, tuple.Navigation); - }) + var links = markdownInNavigation.Select(tuple => + { + var path = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? tuple.Markdown.LinkReferenceRelativePath.Replace('\\', '/') + : tuple.Markdown.LinkReferenceRelativePath; + return (Path: path, tuple.Markdown, tuple.Navigation); + }) .DistinctBy(tuple => tuple.Path) .OrderBy(tuple => tuple.Path) .ToDictionary( @@ -285,12 +284,9 @@ public RepositoryLinks CreateLinkReference() tuple => { var anchors = tuple.Markdown.Anchors.Count == 0 ? null : tuple.Markdown.Anchors.ToArray(); - return new LinkMetadata - { - Anchors = anchors, - Hidden = tuple.Navigation.ExcludeFromIndexing - }; - }); + return new LinkMetadata { Anchors = anchors, Hidden = tuple.Navigation.ExcludeFromIndexing }; + } + ); return new RepositoryLinks { diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs index 2de8f43917..6a5c34f228 100644 --- a/src/Elastic.Markdown/IO/MarkdownFile.cs +++ b/src/Elastic.Markdown/IO/MarkdownFile.cs @@ -31,13 +31,11 @@ public record MarkdownFile : DocumentationFile, ITableOfContentsScope, IDocument private readonly IReadOnlyDictionary _globalSubstitutions; - public MarkdownFile( - IFileInfo sourceFile, - IDirectoryInfo rootPath, - MarkdownParser parser, - BuildContext build - ) - : base(sourceFile, rootPath, build.Git.RepositoryName) + public MarkdownFile(IFileInfo sourceFile, IDirectoryInfo rootPath, MarkdownParser parser, BuildContext build) : base( + sourceFile, + rootPath, + build.Git.RepositoryName + ) { FileName = sourceFile.Name; FilePath = sourceFile.FullName; @@ -92,7 +90,6 @@ public virtual string NavigationTitle public virtual string? RedirectUrl => null; - //indexed by slug private readonly Dictionary _pageTableOfContent = [with(StringComparer.OrdinalIgnoreCase)]; public IReadOnlyDictionary PageTableOfContent => _pageTableOfContent; @@ -175,9 +172,7 @@ private IReadOnlyDictionary GetSubstitutions() protected void ReadDocumentInstructions(MarkdownDocument document, Func documentationFileLookup) { - Title = document - .FirstOrDefault(block => block is HeadingBlock { Level: 1 })? - .GetData("header") as string ?? Title; + Title = document.FirstOrDefault(block => block is HeadingBlock { Level: 1 })?.GetData("header") as string ?? Title; if (Title == RelativePath) Title = FindNestedTitle(document) ?? Title; @@ -188,7 +183,10 @@ protected void ReadDocumentInstructions(MarkdownDocument document, Func().Any()) - Collector.EmitError(FilePath, "A page with `layout: hub` requires a {hero} directive. Without it the page renders without a title."); + Collector.EmitError( + FilePath, + "A page with `layout: hub` requires a {hero} directive. Without it the page renders without a title." + ); if (yamlFrontMatter.NavigationTitle is not null) NavigationTitle = yamlFrontMatter.NavigationTitle; @@ -214,7 +212,6 @@ protected void ReadDocumentInstructions(MarkdownDocument document, Func GetAnchors( YamlFrontMatter? frontMatter, MarkdownDocument document, IReadOnlyDictionary subs, - out string[] anchors) + out string[] anchors + ) { // Single traversal — collects typed lists in DFS order. // We also track the last heading seen so that IncludeBlocks can be annotated @@ -264,8 +262,7 @@ public static List GetAnchors( } } - var includes = includeContexts - .Where(t => t.Block.Found) + var includes = includeContexts.Where(t => t.Block.Found) .Select(t => { var relativePath = t.Block.IncludePathRelativeToSource; @@ -281,32 +278,28 @@ public static List GetAnchors( .Where(i => i is not null) .ToArray(); - var includedTocs = includes - .SelectMany(i => - { - var precedingLevel = i!.PrecedingHeadingLevel; + var includedTocs = includes.SelectMany(i => + { + var precedingLevel = i!.PrecedingHeadingLevel; - return i.Anchors!.TableOfContentItems - .Select(item => - { - // Only adjust stepper steps, not regular headings - // Stepper steps default to level 2 when parsed in isolation (no preceding heading in snippet), - // but should be relative to the preceding heading in the parent document - var adjustedItem = item; - if (item.IsStepperStep && precedingLevel.HasValue && item.Level == 2) - { - // The step was parsed without context (defaulted to h2) - // Adjust it to be one level deeper than the preceding heading - adjustedItem = item with { Level = Math.Min(precedingLevel.Value + 1, 6) }; - } - return new { TocItem = adjustedItem, i.Block.Line }; - }); - }) - .ToArray(); + return i.Anchors!.TableOfContentItems.Select(item => + { + // Only adjust stepper steps, not regular headings + // Stepper steps default to level 2 when parsed in isolation (no preceding heading in snippet), + // but should be relative to the preceding heading in the parent document + var adjustedItem = item; + if (item.IsStepperStep && precedingLevel.HasValue && item.Level == 2) + { + // The step was parsed without context (defaulted to h2) + // Adjust it to be one level deeper than the preceding heading + adjustedItem = item with { Level = Math.Min(precedingLevel.Value + 1, 6) }; + } + return new { TocItem = adjustedItem, i.Block.Line }; + }); + }).ToArray(); // Collect headings from standard markdown (already have the list — no second traversal) - var headingTocs = headings - .Where(block => block is { Level: >= 2 }) + var headingTocs = headings.Where(block => block is { Level: >= 2 }) .Select(h => (h.GetData("header") as string, h.GetData("anchor") as string, h.Level, h.Line)) .Where(h => h.Item1 is not null) .Select(h => @@ -314,19 +307,13 @@ public static List GetAnchors( var header = h.Item1!.StripMarkdown(); return new { - TocItem = new PageTocItem - { - Heading = header, - Slug = (h.Item2 ?? h.Item1).Slugify(), - Level = h.Level - }, + TocItem = new PageTocItem { Heading = header, Slug = (h.Item2 ?? h.Item1).Slugify(), Level = h.Level }, h.Line }; }); // Collect headings from Stepper steps (filter from already-collected directives) - var stepperTocs = directives - .OfType() + var stepperTocs = directives.OfType() .Where(step => !string.IsNullOrEmpty(step.Title)) .Where(step => !IsNestedInOtherDirective(step)) .Select(step => @@ -343,6 +330,7 @@ public static List GetAnchors( Heading = processedTitle, Slug = step.Anchor, Level = step.HeadingLevel, // Use dynamic heading level + IsStepperStep = true }, step.Line @@ -350,37 +338,33 @@ public static List GetAnchors( }); // Collect headings from Changelog directives - var changelogTocs = directives - .OfType() - .SelectMany(changelog => changelog.GeneratedTableOfContent - .Select(tocItem => new { TocItem = tocItem, changelog.Line })); + var changelogTocs = directives.OfType().SelectMany( + changelog => changelog.GeneratedTableOfContent.Select(tocItem => new { TocItem = tocItem, changelog.Line }) + ); // Collect settings group headings (h2) from {settings} directives - var settingsTocs = directives - .OfType() + var settingsTocs = directives.OfType() .Where(settings => !IsNestedInOtherDirective(settings)) - .SelectMany(settings => settings.GeneratedTableOfContent - .Select(tocItem => new { TocItem = tocItem, settings.Line })); + .SelectMany(settings => settings.GeneratedTableOfContent.Select(tocItem => new { TocItem = tocItem, settings.Line })); - var toc = headingTocs - .Concat(stepperTocs) + var toc = headingTocs.Concat(stepperTocs) .Concat(changelogTocs) .Concat(settingsTocs) .Concat(includedTocs) .OrderBy(item => item.Line) .Select(item => item.TocItem) - .Select(toc => subs.Count == 0 - ? toc - : toc.Heading.AsSpan().ReplaceSubstitutions(subs, collector, out var r) - ? toc with { Heading = r } - : toc) + .Select( + toc => + subs.Count == 0 + ? toc + : toc.Heading.AsSpan().ReplaceSubstitutions(subs, collector, out var r) ? toc with { Heading = r } : toc + ) .ToList(); var includedAnchors = includes.SelectMany(i => i!.Anchors!.Anchors).ToArray(); anchors = [ - ..directives - .Select(b => b.CrossReferenceName) + .. directives.Select(b => b.CrossReferenceName) .Where(l => !string.IsNullOrWhiteSpace(l)) .Select(s => s.Slugify()) .Concat(directives.SelectMany(b => b.GeneratedAnchors)) @@ -424,8 +408,15 @@ private YamlFrontMatter ProcessYamlFrontMatter(MarkdownDocument document) { foreach (var url in fm.MappedPages) { - if (!string.IsNullOrEmpty(url) && (!url.StartsWith("https://www.elastic.co/guide", StringComparison.OrdinalIgnoreCase) || !Uri.IsWellFormedUriString(url, UriKind.Absolute))) - Collector.EmitError(FilePath, $"Invalid mapped_pages URL: \"{url}\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\". Please update the URL to reference content under the Elastic documentation guide."); + if ( + !string.IsNullOrEmpty(url) && + (!url.StartsWith("https://www.elastic.co/guide", StringComparison.OrdinalIgnoreCase) || + !Uri.IsWellFormedUriString(url, UriKind.Absolute)) + ) + Collector.EmitError( + FilePath, + $"Invalid mapped_pages URL: \"{url}\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\". Please update the URL to reference content under the Elastic documentation guide." + ); } } diff --git a/src/Elastic.Markdown/IO/MarkdownFileFactory.cs b/src/Elastic.Markdown/IO/MarkdownFileFactory.cs index dc2611661d..12952e5608 100644 --- a/src/Elastic.Markdown/IO/MarkdownFileFactory.cs +++ b/src/Elastic.Markdown/IO/MarkdownFileFactory.cs @@ -42,22 +42,24 @@ public class MarkdownFileFactory : IDocumentationFileFactory private readonly BuildContext _context; private readonly MarkdownParser _markdownParser; - public MarkdownFileFactory(BuildContext context, MarkdownParser markdownParser, IReadOnlyCollection enabledExtensions) + public MarkdownFileFactory( + BuildContext context, + MarkdownParser markdownParser, + IReadOnlyCollection enabledExtensions + ) { _context = context; _markdownParser = markdownParser; EnabledExtensions = enabledExtensions; var files = ScanDocumentationFiles(context, context.DocumentationSourceDirectory); - var additionalSources = enabledExtensions - .SelectMany(extension => extension.ScanDocumentationFiles(DefaultFileHandling)) - .ToArray(); - - Files = files.Concat(additionalSources) - .Where(t => t.Item2 is not ExcludedFile) - .ToDictionary(kv => new FilePath(kv.Item1, context.DocumentationSourceDirectory), kv => kv.Item2) - .ToFrozenDictionary(); + var additionalSources = enabledExtensions.SelectMany(extension => extension.ScanDocumentationFiles(DefaultFileHandling)).ToArray(); + Files = + files.Concat(additionalSources) + .Where(t => t.Item2 is not ExcludedFile) + .ToDictionary(kv => new FilePath(kv.Item1, context.DocumentationSourceDirectory), kv => kv.Item2) + .ToFrozenDictionary(); } public FrozenDictionary Files { get; } @@ -84,48 +86,59 @@ public MarkdownFileFactory(BuildContext context, MarkdownParser markdownParser, // IDirectoryInfo wrapper and triggered a stat for every file). var dirAttrCache = new Dictionary(StringComparer.Ordinal); - return [.. build.ReadFileSystem.Directory - .EnumerateFiles(sourceDirectory.FullName, "*.*", SearchOption.AllDirectories) - // Compute relative path once from the raw string before IFileInfo allocation. - // This also lets us do the hidden-folder dot-prefix check with zero metadata syscalls. - .Select(path => (path, relative: Path.GetRelativePath(sourceDirectory.FullName, path))) - // Skip dot-prefixed paths (Unix hidden dirs) — pure string, no stat - .Where(t => !t.relative.StartsWith('.')) - // Now create the IFileInfo (triggers stat on first property access) - .Select(t => (file: build.ReadFileSystem.FileInfo.New(t.path), t.relative)) - .Where(t => - { - // Single Attributes read covers hidden, system, and symlink (ReparsePoint) checks; - // the original code read Attributes twice for the file and twice more via Directory. - var fileAttr = t.file.Attributes; - if (fileAttr.HasFlag(FileAttributes.Hidden) || fileAttr.HasFlag(FileAttributes.System)) - return false; - // Skip symlinks - if (t.file.LinkTarget != null) - return false; - // Check parent directory attributes with per-directory caching - var dirPath = Path.GetDirectoryName(t.file.FullName)!; - if (!dirAttrCache.TryGetValue(dirPath, out var dirAttr)) - { - dirAttr = build.ReadFileSystem.DirectoryInfo.New(dirPath).Attributes; - dirAttrCache[dirPath] = dirAttr; - } - return !dirAttr.HasFlag(FileAttributes.Hidden) && !dirAttr.HasFlag(FileAttributes.System); - }) - .Select<(IFileInfo file, string relative), (IFileInfo, DocumentationFile)>(t => - t.file.Extension switch + return [ + .. build.ReadFileSystem + .Directory + .EnumerateFiles(sourceDirectory.FullName, "*.*", SearchOption.AllDirectories) + // Compute relative path once from the raw string before IFileInfo allocation. + // This also lets us do the hidden-folder dot-prefix check with zero metadata syscalls. + .Select(path => (path, relative: Path.GetRelativePath(sourceDirectory.FullName, path))) + // Skip dot-prefixed paths (Unix hidden dirs) — pure string, no stat + .Where(t => !t.relative.StartsWith('.')) + // Now create the IFileInfo (triggers stat on first property access) + .Select(t => (file: build.ReadFileSystem.FileInfo.New(t.path), t.relative)) + .Where(t => { - ".jpg" => (t.file, CreateImageFile(t.file, sourceDirectory, build, t.relative, "image/jpeg")), - ".jpeg" => (t.file, CreateImageFile(t.file, sourceDirectory, build, t.relative, "image/jpeg")), - ".gif" => (t.file, CreateImageFile(t.file, sourceDirectory, build, t.relative, "image/gif")), - ".svg" => (t.file, CreateImageFile(t.file, sourceDirectory, build, t.relative, "image/svg+xml")), - ".png" => (t.file, CreateImageFile(t.file, sourceDirectory, build, t.relative)), - ".md" => CreateMarkdownTuple(t.file, build), - _ => (t.file, DefaultFileHandling(t.file, sourceDirectory)) - })]; + // Single Attributes read covers hidden, system, and symlink (ReparsePoint) checks; + // the original code read Attributes twice for the file and twice more via Directory. + var fileAttr = t.file.Attributes; + if (fileAttr.HasFlag(FileAttributes.Hidden) || fileAttr.HasFlag(FileAttributes.System)) + return false; + // Skip symlinks + if (t.file.LinkTarget != null) + return false; + // Check parent directory attributes with per-directory caching + var dirPath = Path.GetDirectoryName(t.file.FullName)!; + if (!dirAttrCache.TryGetValue(dirPath, out var dirAttr)) + { + dirAttr = build.ReadFileSystem.DirectoryInfo.New(dirPath).Attributes; + dirAttrCache[dirPath] = dirAttr; + } + return !dirAttr.HasFlag(FileAttributes.Hidden) && !dirAttr.HasFlag(FileAttributes.System); + }) + .Select<(IFileInfo file, string relative), (IFileInfo, DocumentationFile)>( + t => + t.file.Extension switch + { + ".jpg" => (t.file, CreateImageFile(t.file, sourceDirectory, build, t.relative, "image/jpeg")), + ".jpeg" => (t.file, CreateImageFile(t.file, sourceDirectory, build, t.relative, "image/jpeg")), + ".gif" => (t.file, CreateImageFile(t.file, sourceDirectory, build, t.relative, "image/gif")), + ".svg" => (t.file, CreateImageFile(t.file, sourceDirectory, build, t.relative, "image/svg+xml")), + ".png" => (t.file, CreateImageFile(t.file, sourceDirectory, build, t.relative)), + ".md" => CreateMarkdownTuple(t.file, build), + _ => (t.file, DefaultFileHandling(t.file, sourceDirectory)) + } + ) + ]; } - private DocumentationFile CreateImageFile(IFileInfo file, IDirectoryInfo sourceDirectory, BuildContext context, string relativePath, string mimeType = "image/png") + private DocumentationFile CreateImageFile( + IFileInfo file, + IDirectoryInfo sourceDirectory, + BuildContext context, + string relativePath, + string mimeType = "image/png" + ) { if (context.Configuration.IsExcluded(relativePath)) return new ExcludedFile(file, sourceDirectory, context.Git.RepositoryName); @@ -148,8 +161,10 @@ private DocumentationFile CreateMarkDownFile(IFileInfo file, BuildContext contex if (context.Configuration.IsExcluded(relativePath)) return new ExcludedFile(file, sourceDirectory, context.Git.RepositoryName); - if (relativePath.Contains($"{Path.DirectorySeparatorChar}_snippets{Path.DirectorySeparatorChar}") - || relativePath.StartsWith($"_snippets{Path.DirectorySeparatorChar}")) + if ( + relativePath.Contains($"{Path.DirectorySeparatorChar}_snippets{Path.DirectorySeparatorChar}") || + relativePath.StartsWith($"_snippets{Path.DirectorySeparatorChar}") + ) return new SnippetFile(file, sourceDirectory, context.Git.RepositoryName); // we ignore files in folders that start with an underscore @@ -177,7 +192,6 @@ MarkdownFile ExtensionOrDefaultMarkdown() } } - private DocumentationFile DefaultFileHandling(IFileInfo file, IDirectoryInfo sourceDirectory) { foreach (var extension in EnabledExtensions) @@ -188,5 +202,4 @@ private DocumentationFile DefaultFileHandling(IFileInfo file, IDirectoryInfo sou } return new ExcludedFile(file, sourceDirectory, _context.Git.RepositoryName); } - } diff --git a/src/Elastic.Markdown/MarkdownPageLayout.cs b/src/Elastic.Markdown/MarkdownPageLayout.cs index 7f80d07134..561980e64c 100644 --- a/src/Elastic.Markdown/MarkdownPageLayout.cs +++ b/src/Elastic.Markdown/MarkdownPageLayout.cs @@ -8,9 +8,14 @@ namespace Elastic.Markdown; public enum MarkdownPageLayout { - [EnumMember(Value = "landing-page")] LandingPage, - [EnumMember(Value = "not-found")] NotFound, - [EnumMember(Value = "archive")] Archive, - [EnumMember(Value = "full-search")] FullSearch, - [EnumMember(Value = "hub")] Hub + [EnumMember(Value = "landing-page")] + LandingPage, + [EnumMember(Value = "not-found")] + NotFound, + [EnumMember(Value = "archive")] + Archive, + [EnumMember(Value = "full-search")] + FullSearch, + [EnumMember(Value = "hub")] + Hub } diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/CodeViewModel.cs b/src/Elastic.Markdown/Myst/CodeBlocks/CodeViewModel.cs index 0e3179bf38..990d80b9dd 100644 --- a/src/Elastic.Markdown/Myst/CodeBlocks/CodeViewModel.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/CodeViewModel.cs @@ -28,9 +28,7 @@ public HtmlString RenderBlock() EnhancedCodeBlockHtmlRenderer.RenderCodeBlockLines(subscription.HtmlRenderer, EnhancedCodeBlock); var result = subscription.RentedStringBuilder?.ToString(); DocumentationObjectPoolProvider.HtmlRendererPool.Return(subscription); - return result == null - ? HtmlString.Empty - : new HtmlString(result.EnsureTrimmed()); + return result == null ? HtmlString.Empty : new HtmlString(result.EnsureTrimmed()); } public HtmlString RenderLineWithCallouts(string content, int lineNumber) diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs index fdf7af77bd..99d9a2138b 100644 --- a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs @@ -18,8 +18,7 @@ public class ApiSegment public List<(string Content, int LineNumber)> ContentLinesWithNumbers { get; set; } = []; } -public class EnhancedCodeBlock(BlockParser parser, ParserContext context) - : FencedCodeBlock(parser), IBlockExtension +public class EnhancedCodeBlock(BlockParser parser, ParserContext context) : FencedCodeBlock(parser), IBlockExtension { public BuildContext Build { get; } = context.Build; diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs index 2344aa8995..1d164f846e 100644 --- a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs @@ -86,10 +86,8 @@ private static void RenderCallouts(HtmlRenderer renderer, EnhancedCodeBlock bloc } } - private static IEnumerable FindCallouts( - IEnumerable callOuts, - int lineNumber - ) => callOuts.Where(callOut => callOut.Line == lineNumber); + private static IEnumerable FindCallouts(IEnumerable callOuts, int lineNumber) => + callOuts.Where(callOut => callOut.Line == lineNumber); private static int GetCommonIndent(EnhancedCodeBlock block) { @@ -105,7 +103,6 @@ private static int GetCommonIndent(EnhancedCodeBlock block) return commonIndent; } - private static int CountIndentation(StringSlice slice) { var indentCount = 0; @@ -149,6 +146,7 @@ protected override void Write(HtmlRenderer renderer, EnhancedCodeBlock block) var slice = Code.Create(new CodeViewModel { CrossReferenceName = string.Empty,// block.CrossReferenceName, + Language = block.Language, Caption = block.Caption, ApiSegments = block.ApiSegments, @@ -262,7 +260,8 @@ private static HtmlString RenderCalloutMarkdown(EnhancedCodeBlock block, CallOut callOut.Text, block.CurrentFile, block.Context.YamlFrontMatter, - MarkdownParser.Pipeline); + MarkdownParser.Pipeline + ); if (document.Count == 1 && document.FirstOrDefault() is ParagraphBlock paragraph && paragraph.Inline != null) return RenderInlineMarkdown(paragraph); @@ -306,10 +305,7 @@ private static void RenderAppliesToHtml(HtmlRenderer renderer, AppliesToDirectiv private static void RenderContributorsHtml(HtmlRenderer renderer, ContributorsBlock block) { - var slice = ContributorsView.Create(new ContributorsViewModel - { - Contributors = block.Contributors - }); + var slice = ContributorsView.Create(new ContributorsViewModel { Contributors = block.Contributors }); RenderRazorSlice(slice, renderer); } @@ -317,29 +313,53 @@ private static void RenderContributorsHtml(HtmlRenderer renderer, ContributorsBl // Authors reference these via :::classname or `class Node classname` syntax. private static readonly IReadOnlyList MermaidAllowedClasses = [ - new DiagramClass { Name = "note", Fill = "#e8f1ff", Stroke = "#a3cbff", Color = "#154399" }, // blue-elastic ramp-10/40/120 - new DiagramClass { Name = "tip", Fill = "#e2f9f7", Stroke = "#77e5e0", Color = "#065b58" }, // teal ramp-10/40/120 - new DiagramClass { Name = "warning", Fill = "#fdf3d8", Stroke = "#facb3d", Color = "#6a4906" }, // yellow ramp-10/40/110 + new DiagramClass { Name = "note", Fill = "#e8f1ff", Stroke = "#a3cbff", Color = "#154399" }, // blue-elastic ramp-10/40/120 + + new DiagramClass { Name = "tip", Fill = "#e2f9f7", Stroke = "#77e5e0", Color = "#065b58" }, // teal ramp-10/40/120 + + new DiagramClass { Name = "warning", Fill = "#fdf3d8", Stroke = "#facb3d", Color = "#6a4906" }, // yellow ramp-10/40/110 + new DiagramClass { Name = "important", Fill = "#f3ecfe", Stroke = "#d1bafc", Color = "#52357e" }, // purple ramp-10/40/110 - new DiagramClass { Name = "caution", Fill = "#ffefe9", Stroke = "#ffc1aa", Color = "#8a3825" }, // poppy ramp-10/40/120 - new DiagramClass { Name = "error", Fill = "#ffe8e5", Stroke = "#ffb5ad", Color = "#7f1f27" }, // red ramp-10/40/120 - new DiagramClass { Name = "success", Fill = "#e2f8f0", Stroke = "#88e3c3", Color = "#0c5a3f" }, // green ramp-10/40/110 - new DiagramClass { Name = "plain", Fill = "#f6f9fc", Stroke = "#bdc2ca", Color = "#464c56" }, // grey ramp-10/40/110 - new DiagramClass { Name = "highlight", Fill = "#d9e8ff", Stroke = "#3788ff", Color = "#123778" }, // blue-elastic ramp-20/70/130 — active/selected + + new DiagramClass { Name = "caution", Fill = "#ffefe9", Stroke = "#ffc1aa", Color = "#8a3825" }, // poppy ramp-10/40/120 + + new DiagramClass { Name = "error", Fill = "#ffe8e5", Stroke = "#ffb5ad", Color = "#7f1f27" }, // red ramp-10/40/120 + + new DiagramClass { Name = "success", Fill = "#e2f8f0", Stroke = "#88e3c3", Color = "#0c5a3f" }, // green ramp-10/40/110 + + new DiagramClass { Name = "plain", Fill = "#f6f9fc", Stroke = "#bdc2ca", Color = "#464c56" }, // grey ramp-10/40/110 + + new DiagramClass + { + Name = "highlight", + Fill = "#d9e8ff", + Stroke = "#3788ff", + Color = "#123778" + }, // blue-elastic ramp-20/70/130 — active/selected + ]; // Categorical data palette for pie/sankey/timeline etc. One vivid, distinct step per theme.css hue. private static readonly string[] MermaidDataPalette = [ "#3788ff", // blue-elastic-70 + "#ee4c48", // red-70 + "#04ae7e", // green-70 + "#a36def", // purple-70 + "#eaae01", // yellow-60 + "#16c5c0", // teal-60 + "#e54a91", // pink-70 + "#ff8659", // poppy-70 + "#36b9ff", // blue-sky + ]; /// Renders a Mermaid code block as an external SVG file referenced via an img element. @@ -357,15 +377,21 @@ private static void RenderMermaidBlock(HtmlRenderer renderer, EnhancedCodeBlock { AllowedClasses = MermaidAllowedClasses, // Strip mode: stray styling is dropped and the diagram still renders; fires once per diagram. - OnStripped = violations => block.EmitHint( - $"Mermaid strict mode stripped {violations.Count} item(s): " + - string.Join(", ", violations.Select(v => $"{v.Kind} (line {v.Line})"))), + OnStripped = + violations => + block.EmitHint( + $"Mermaid strict mode stripped {violations.Count} item(s): " + + string.Join(", ", violations.Select(v => $"{v.Kind} (line {v.Line})")) + ), }, // Logged as errors for now so SVG sanitizer removals cause build failures — downgrade to warnings // once we're confident the sanitizer isn't stripping legitimate content. - OnSanitized = violations => block.EmitError( - $"Mermaid SVG sanitizer removed {violations.Count} item(s): " + - string.Join(", ", violations.Select(v => $"{v.Kind} '{v.Name}'"))), + OnSanitized = + violations => + block.EmitError( + $"Mermaid SVG sanitizer removed {violations.Count} item(s): " + + string.Join(", ", violations.Select(v => $"{v.Kind} '{v.Name}'")) + ), }; string svg; diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockParser.cs b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockParser.cs index a46d4dc57c..40854b9bd7 100644 --- a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockParser.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockParser.cs @@ -81,11 +81,7 @@ public override bool Close(BlockProcessor processor, Block block) if (processor.Context is not ParserContext context) throw new Exception("Expected parser context to be of type ParserContext"); - codeBlock.Language = ( - (codeBlock.Info?.IndexOf('{') ?? -1) != -1 - ? codeBlock.Arguments?.Split()[0] - : codeBlock.Info - ) ?? "unknown"; + codeBlock.Language = ((codeBlock.Info?.IndexOf('{') ?? -1) != -1 ? codeBlock.Arguments?.Split()[0] : codeBlock.Info) ?? "unknown"; var language = codeBlock.Language; codeBlock.Language = language switch @@ -137,10 +133,7 @@ private static void ProcessAppliesToDirective(AppliesToDirective appliesToDirect } } - private static void ProcessContributorsDirective( - ContributorsBlock contributorsBlock, - StringLineGroup lines, - ParserContext context) + private static void ProcessContributorsDirective(ContributorsBlock contributorsBlock, StringLineGroup lines, ParserContext context) { var yaml = lines.ToSlice().AsSpan().ToString(); @@ -155,11 +148,7 @@ private static void ProcessContributorsDirective( } } - private static void ProcessCodeBlock( - StringLineGroup lines, - string language, - EnhancedCodeBlock codeBlock, - ParserContext context) + private static void ProcessCodeBlock(StringLineGroup lines, string language, EnhancedCodeBlock codeBlock, ParserContext context) { string argsString; if (codeBlock.Arguments == null) @@ -170,14 +159,14 @@ private static void ProcessCodeBlock( { // if the code block starts with {code-block} and is followed by a language, we need to skip the language var parts = codeBlock.Arguments.Split(); - argsString = parts.Length > 1 && CodeBlock.Languages.Contains(parts[0]) - ? string.Join(" ", parts[1..]) - : codeBlock.Arguments; + argsString = parts.Length > 1 && CodeBlock.Languages.Contains(parts[0]) ? string.Join(" ", parts[1..]) : codeBlock.Arguments; } var codeBlockArgs = CodeBlockArguments.Default; if (!CodeBlockArguments.TryParse(argsString, out var codeArgs)) - codeBlock.EmitError($"Unable to parse code block arguments: {argsString}. Valid arguments are {CodeBlockArguments.KnownKeysString}."); + codeBlock.EmitError( + $"Unable to parse code block arguments: {argsString}. Valid arguments are {CodeBlockArguments.KnownKeysString}." + ); else codeBlockArgs = codeArgs; @@ -224,11 +213,13 @@ private static void ProcessCodeBlock( ProcessInlineAnnotations(codeBlock); } - private static List EnumerateAnnotations(Regex.ValueMatchEnumerator matches, + private static List EnumerateAnnotations( + Regex.ValueMatchEnumerator matches, ref ReadOnlySpan span, ref int callOutIndex, int originatingLine, - bool inlineCodeAnnotation) + bool inlineCodeAnnotation + ) { var callOuts = new List(); foreach (var match in matches) @@ -270,7 +261,12 @@ private static List EnumerateAnnotations(Regex.ValueMatchEnumerator mat }; } - private static List ParseClassicCallOuts(ValueMatch match, ref ReadOnlySpan span, ref int callOutIndex, int originatingLine) + private static List ParseClassicCallOuts( + ValueMatch match, + ref ReadOnlySpan span, + ref int callOutIndex, + int originatingLine + ) { var indexOfLastComment = Math.Max(span.LastIndexOf(" # "), span.LastIndexOf(" // ")); var startIndex = span.LastIndexOf('<'); @@ -310,7 +306,8 @@ private static void ProcessConsoleCodeBlock( StringLineGroup lines, EnhancedCodeBlock codeBlock, CodeBlockArguments codeBlockArgs, - ParserContext context) + ParserContext context + ) { var currentSegment = new ApiSegment(); var callOutIndex = 0; @@ -353,11 +350,7 @@ private static void ProcessConsoleCodeBlock( if (codeBlockArgs.UseCallouts && codeBlock.OpeningFencedCharCount <= 3) ProcessCalloutsForLine(span, codeBlock, ref callOutIndex, originatingLine); - currentSegment = new ApiSegment - { - Header = lineText, - LineNumber = originatingLine - }; + currentSegment = new ApiSegment { Header = lineText, LineNumber = originatingLine }; // Clear this line from the content since it's now a header var s = new StringSlice(""); @@ -387,34 +380,35 @@ private static void ProcessConsoleCodeBlock( private static bool IsHttpVerb(string line) { var trimmed = line.Trim(); - return trimmed.StartsWith("GET ", StringComparison.OrdinalIgnoreCase) || - trimmed.StartsWith("POST ", StringComparison.OrdinalIgnoreCase) || - trimmed.StartsWith("PUT ", StringComparison.OrdinalIgnoreCase) || - trimmed.StartsWith("DELETE ", StringComparison.OrdinalIgnoreCase) || - trimmed.StartsWith("PATCH ", StringComparison.OrdinalIgnoreCase) || - trimmed.StartsWith("HEAD ", StringComparison.OrdinalIgnoreCase) || - trimmed.StartsWith("OPTIONS ", StringComparison.OrdinalIgnoreCase); + return trimmed.StartsWith("GET ", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("POST ", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("PUT ", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("DELETE ", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("PATCH ", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("HEAD ", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("OPTIONS ", StringComparison.OrdinalIgnoreCase); } - private static void ProcessCalloutsForLine(ReadOnlySpan span, EnhancedCodeBlock codeBlock, ref int callOutIndex, int originatingLine) + private static void ProcessCalloutsForLine( + ReadOnlySpan span, + EnhancedCodeBlock codeBlock, + ref int callOutIndex, + int originatingLine + ) { List callOuts = []; var hasClassicCallout = span.IndexOf("<") > 0 && span.LastIndexOf(">") == span.Length - 1; if (hasClassicCallout) { var matchClassicCallout = CallOutParser.CallOutNumber().EnumerateMatches(span); - callOuts.AddRange( - EnumerateAnnotations(matchClassicCallout, ref span, ref callOutIndex, originatingLine, false) - ); + callOuts.AddRange(EnumerateAnnotations(matchClassicCallout, ref span, ref callOutIndex, originatingLine, false)); } // only support magic callouts for smaller line lengths if (callOuts.Count == 0 && span.Length < 200) { var matchInline = CallOutParser.MathInlineAnnotation().EnumerateMatches(span); - callOuts.AddRange( - EnumerateAnnotations(matchInline, ref span, ref callOutIndex, originatingLine, true) - ); + callOuts.AddRange(EnumerateAnnotations(matchInline, ref span, ref callOutIndex, originatingLine, true)); } codeBlock.CallOuts.AddRange(callOuts); @@ -425,14 +419,17 @@ private static void ProcessCalloutPostProcessing(StringLineGroup lines, Enhanced //update string slices to ignore call outs if (codeBlock.CallOuts.Count > 0) { - var callouts = codeBlock.CallOuts.Aggregate(new Dictionary(), (acc, curr) => - { - if (acc.TryAdd(curr.Line, curr)) + var callouts = codeBlock.CallOuts.Aggregate( + new Dictionary(), + (acc, curr) => + { + if (acc.TryAdd(curr.Line, curr)) + return acc; + if (acc[curr.Line].SliceStart > curr.SliceStart) + acc[curr.Line] = curr; return acc; - if (acc[curr.Line].SliceStart > curr.SliceStart) - acc[curr.Line] = curr; - return acc; - }); + } + ); // Console code blocks use ApiSegments for rendering, so we need to update headers directly // Note: console language gets converted to "json" for syntax highlighting diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/SupportedLanguages.cs b/src/Elastic.Markdown/Myst/CodeBlocks/SupportedLanguages.cs index 17fe68449e..e8738d8370 100644 --- a/src/Elastic.Markdown/Myst/CodeBlocks/SupportedLanguages.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/SupportedLanguages.cs @@ -10,55 +10,89 @@ public static class CodeBlock private static readonly IReadOnlyDictionary LanguageMapping = new Dictionary { { "asciidoc", "adoc" }, // AsciiDoc + { "bash", "sh, zsh" }, // Bash + { "c", "h" }, // C + { "csharp", "cs" }, // C# + { "css", "" }, // CSS + { "dockerfile", "docker" }, // Dockerfile + { "dos", "bat, cmd" }, // DOS + { "ebnf", "" }, // EBNF + { "go", "golang" }, // Go + { "gradle", "" }, // Gradle + { "groovy", "" }, // Groovy + { "handlebars", "hbs, html.hbs, html.handlebars" }, // Handlebars + { "http", "https" }, // HTTP + { "ini", "toml" }, // Ini, TOML + { "java", "jsp" }, // Java + { "javascript", "js, jsx" }, // JavaScript + { "json", "jsonc" }, // JSON + { "kotlin", "kt" }, // Kotlin + { "markdown", "md, mkdown, mkd" }, // Markdown + { "nginx", "nginxconf" }, // Nginx + { "php", "" }, // PHP + { "plaintext", "txt, text" }, // Plaintext + { "powershell", "ps, ps1" }, // PowerShell + { "properties", "" }, // Properties + { "python", "py, gyp" }, // Python + { "ruby", "rb, gemspec, podspec, thor, irb" }, // Ruby + { "rust", "rs" }, // Rust + { "scala", "" }, // Scala + { "shell", "console" }, // Shell + { "sql", "" }, // SQL + { "swift", "" }, // Swift + { "typescript", "ts, tsx, mts, cts" }, // TypeScript + { "xml", "html, xhtml, rss, atom, xjb, xsd, xsl, plist, svg" }, // HTML, XML + { "yml", "yaml" }, // YAML - //CUSTOM, Elastic language we wrote highlighters for - { "apiheader", "" }, - { "eql", "" }, - { "esql", "" }, - { "kuery", "kql" }, - { "mermaid", "" }, - { "painless", "" } + //CUSTOM, Elastic language we wrote highlighters for + { + "apiheader", + "" + }, + { "eql", "" }, + { "esql", "" }, + { "kuery", "kql" }, + { "mermaid", "" }, + { "painless", "" } }; public static HashSet Languages { get; } = new( - LanguageMapping.Keys - .Concat(LanguageMapping.Values - .SelectMany(v => v.Split(',').Select(a => a.Trim())) - .Where(v => !string.IsNullOrWhiteSpace(v)) - ) - , StringComparer.OrdinalIgnoreCase + LanguageMapping.Keys.Concat( + LanguageMapping.Values.SelectMany(v => v.Split(',').Select(a => a.Trim())).Where(v => !string.IsNullOrWhiteSpace(v)) + ), + StringComparer.OrdinalIgnoreCase ); } diff --git a/src/Elastic.Markdown/Myst/Comments/CommentBlockParser.cs b/src/Elastic.Markdown/Myst/Comments/CommentBlockParser.cs index 5c57010952..66411c3dde 100644 --- a/src/Elastic.Markdown/Myst/Comments/CommentBlockParser.cs +++ b/src/Elastic.Markdown/Myst/Comments/CommentBlockParser.cs @@ -12,9 +12,7 @@ namespace Elastic.Markdown.Myst.Comments; public class CommentRenderer : HtmlObjectRenderer { - protected override void Write(HtmlRenderer renderer, CommentBlock obj) - { - } + protected override void Write(HtmlRenderer renderer, CommentBlock obj) { } } [DebuggerDisplay("{GetType().Name} Line: {Line}, {Lines} Level: {Level}")] @@ -107,9 +105,9 @@ public override BlockState TryOpen(BlockProcessor processor) // The optional closing sequence of #s must be preceded by a space and may be followed by spaces only. var endState = 0; var countClosingTags = 0; - for (var i = processor.Line.End; - i >= processor.Line.Start - 1; - i--) // Go up to Start - 1 in order to match the space after the first ### + for (var i = processor.Line.End; i >= + processor.Line.Start - 1; i--) // Go up to Start - 1 in order to match the space after the first ### + { c = processor.Line.Text[i]; if (endState == 0) diff --git a/src/Elastic.Markdown/Myst/Comments/MultipleLineCommentBlockParser.cs b/src/Elastic.Markdown/Myst/Comments/MultipleLineCommentBlockParser.cs index 14574684f8..92a5a23513 100644 --- a/src/Elastic.Markdown/Myst/Comments/MultipleLineCommentBlockParser.cs +++ b/src/Elastic.Markdown/Myst/Comments/MultipleLineCommentBlockParser.cs @@ -23,14 +23,7 @@ public override BlockState TryOpen(BlockProcessor processor) var currentLine = processor.Line; if (currentLine.Match(BlockStart)) { - var block = new MultipleLineCommentBlock(this) - { - Column = processor.Column, - Span = - { - Start = processor.Start - } - }; + var block = new MultipleLineCommentBlock(this) { Column = processor.Column, Span = { Start = processor.Start } }; processor.NewBlocks.Push(block); // Check if the closing --> is on the same line (single-line comment) diff --git a/src/Elastic.Markdown/Myst/Components/ApplicabilityMappings.cs b/src/Elastic.Markdown/Myst/Components/ApplicabilityMappings.cs index 71a09c0d51..cbf7af4a63 100644 --- a/src/Elastic.Markdown/Myst/Components/ApplicabilityMappings.cs +++ b/src/Elastic.Markdown/Myst/Components/ApplicabilityMappings.cs @@ -14,48 +14,151 @@ public record ApplicabilityDefinition(string Key, string DisplayName, Versioning public static readonly ApplicabilityDefinition Stack = new("Stack", "Elastic Stack", VersioningSystemId.Stack); // Serverless - public static readonly ApplicabilityDefinition Serverless = new("Serverless", "Elastic Cloud Serverless", VersioningSystemId.Serverless); - public static readonly ApplicabilityDefinition ServerlessElasticsearch = new("Serverless Elasticsearch", "Serverless Elasticsearch projects", VersioningSystemId.ElasticsearchProject); - public static readonly ApplicabilityDefinition ServerlessObservability = new("Serverless Observability", "Serverless Observability projects", VersioningSystemId.ObservabilityProject); - public static readonly ApplicabilityDefinition ServerlessSecurity = new("Serverless Security", "Serverless Security projects", VersioningSystemId.SecurityProject); + public static readonly ApplicabilityDefinition Serverless = new( + "Serverless", + "Elastic Cloud Serverless", + VersioningSystemId.Serverless + ); + public static readonly ApplicabilityDefinition ServerlessElasticsearch = new( + "Serverless Elasticsearch", + "Serverless Elasticsearch projects", + VersioningSystemId.ElasticsearchProject + ); + public static readonly ApplicabilityDefinition ServerlessObservability = new( + "Serverless Observability", + "Serverless Observability projects", + VersioningSystemId.ObservabilityProject + ); + public static readonly ApplicabilityDefinition ServerlessSecurity = new( + "Serverless Security", + "Serverless Security projects", + VersioningSystemId.SecurityProject + ); // Deployment public static readonly ApplicabilityDefinition Ech = new("ECH", "Elastic Cloud Hosted", VersioningSystemId.Ess); public static readonly ApplicabilityDefinition Eck = new("ECK", "Elastic Cloud on Kubernetes", VersioningSystemId.Eck); public static readonly ApplicabilityDefinition Ece = new("ECE", "Elastic Cloud Enterprise", VersioningSystemId.Ece); - public static readonly ApplicabilityDefinition Self = new("Self-managed", "Self-managed Elastic deployments", VersioningSystemId.Self); + public static readonly ApplicabilityDefinition Self = new( + "Self-managed", + "Self-managed Elastic deployments", + VersioningSystemId.Self + ); // Product Applicability public static readonly ApplicabilityDefinition Ecctl = new("ECCTL", "Elastic Cloud Control", VersioningSystemId.Ecctl); public static readonly ApplicabilityDefinition Curator = new("Curator", "Curator", VersioningSystemId.Curator); // Elastic OTel Products - public static readonly ApplicabilityDefinition EdotAndroid = new("EDOT Android", "Elastic Distribution of OpenTelemetry Android", VersioningSystemId.EdotAndroid); - public static readonly ApplicabilityDefinition EdotCfAws = new("Elastic Cloud Forwarder for AWS", "Elastic Cloud Forwarder for AWS", VersioningSystemId.EdotCfAws); - public static readonly ApplicabilityDefinition EdotCfAzure = new("Elastic Cloud Forwarder for Azure", "Elastic Cloud Forwarder for Azure", VersioningSystemId.EdotCfAzure); - public static readonly ApplicabilityDefinition EdotCfGcp = new("Elastic Cloud Forwarder for GCP", "Elastic Cloud Forwarder for GCP", VersioningSystemId.EdotCfGcp); + public static readonly ApplicabilityDefinition EdotAndroid = new( + "EDOT Android", + "Elastic Distribution of OpenTelemetry Android", + VersioningSystemId.EdotAndroid + ); + public static readonly ApplicabilityDefinition EdotCfAws = new( + "Elastic Cloud Forwarder for AWS", + "Elastic Cloud Forwarder for AWS", + VersioningSystemId.EdotCfAws + ); + public static readonly ApplicabilityDefinition EdotCfAzure = new( + "Elastic Cloud Forwarder for Azure", + "Elastic Cloud Forwarder for Azure", + VersioningSystemId.EdotCfAzure + ); + public static readonly ApplicabilityDefinition EdotCfGcp = new( + "Elastic Cloud Forwarder for GCP", + "Elastic Cloud Forwarder for GCP", + VersioningSystemId.EdotCfGcp + ); public static readonly ApplicabilityDefinition EdotCollector = new("Elastic Agent", "Elastic Agent", VersioningSystemId.EdotCollector); - public static readonly ApplicabilityDefinition EdotDotnet = new("EDOT .NET", "Elastic Distribution of OpenTelemetry .NET", VersioningSystemId.EdotDotnet); - public static readonly ApplicabilityDefinition EdotIos = new("EDOT iOS", "Elastic Distribution of OpenTelemetry iOS", VersioningSystemId.EdotIos); - public static readonly ApplicabilityDefinition EdotJava = new("EDOT Java", "Elastic Distribution of OpenTelemetry Java", VersioningSystemId.EdotJava); - public static readonly ApplicabilityDefinition EdotNode = new("EDOT Node.js", "Elastic Distribution of OpenTelemetry Node.js", VersioningSystemId.EdotNode); - public static readonly ApplicabilityDefinition EdotBrowser = new("EDOT Browser", "Elastic Distribution of OpenTelemetry Browser (RUM)", VersioningSystemId.EdotBrowser); - public static readonly ApplicabilityDefinition EdotPhp = new("EDOT PHP", "Elastic Distribution of OpenTelemetry PHP", VersioningSystemId.EdotPhp); - public static readonly ApplicabilityDefinition EdotPython = new("EDOT Python", "Elastic Distribution of OpenTelemetry Python", VersioningSystemId.EdotPython); + public static readonly ApplicabilityDefinition EdotDotnet = new( + "EDOT .NET", + "Elastic Distribution of OpenTelemetry .NET", + VersioningSystemId.EdotDotnet + ); + public static readonly ApplicabilityDefinition EdotIos = new( + "EDOT iOS", + "Elastic Distribution of OpenTelemetry iOS", + VersioningSystemId.EdotIos + ); + public static readonly ApplicabilityDefinition EdotJava = new( + "EDOT Java", + "Elastic Distribution of OpenTelemetry Java", + VersioningSystemId.EdotJava + ); + public static readonly ApplicabilityDefinition EdotNode = new( + "EDOT Node.js", + "Elastic Distribution of OpenTelemetry Node.js", + VersioningSystemId.EdotNode + ); + public static readonly ApplicabilityDefinition EdotBrowser = new( + "EDOT Browser", + "Elastic Distribution of OpenTelemetry Browser (RUM)", + VersioningSystemId.EdotBrowser + ); + public static readonly ApplicabilityDefinition EdotPhp = new( + "EDOT PHP", + "Elastic Distribution of OpenTelemetry PHP", + VersioningSystemId.EdotPhp + ); + public static readonly ApplicabilityDefinition EdotPython = new( + "EDOT Python", + "Elastic Distribution of OpenTelemetry Python", + VersioningSystemId.EdotPython + ); // APM Agents - public static readonly ApplicabilityDefinition ApmAgentAndroid = new("APM Agent Android", "Application Performance Monitoring Agent for Android", VersioningSystemId.ApmAgentAndroid); - public static readonly ApplicabilityDefinition ApmAgentDotnet = new("APM Agent .NET", "Application Performance Monitoring Agent for .NET", VersioningSystemId.ApmAgentDotnet); - public static readonly ApplicabilityDefinition ApmAgentGo = new("APM Agent Go", "Application Performance Monitoring Agent for Go", VersioningSystemId.ApmAgentGo); - public static readonly ApplicabilityDefinition ApmAgentIos = new("APM Agent iOS", "Application Performance Monitoring Agent for iOS", VersioningSystemId.ApmAgentIos); - public static readonly ApplicabilityDefinition ApmAgentJava = new("APM Agent Java", "Application Performance Monitoring Agent for Java", VersioningSystemId.ApmAgentJava); - public static readonly ApplicabilityDefinition ApmAgentNode = new("APM Agent Node.js", "Application Performance Monitoring Agent for Node.js", VersioningSystemId.ApmAgentNode); - public static readonly ApplicabilityDefinition ApmAgentPhp = new("APM Agent PHP", "Application Performance Monitoring Agent for PHP", VersioningSystemId.ApmAgentPhp); - public static readonly ApplicabilityDefinition ApmAgentPython = new("APM Agent Python", "Application Performance Monitoring Agent for Python", VersioningSystemId.ApmAgentPython); - public static readonly ApplicabilityDefinition ApmAgentRuby = new("APM Agent Ruby", "Application Performance Monitoring Agent for Ruby", VersioningSystemId.ApmAgentRuby); - public static readonly ApplicabilityDefinition ApmAgentRumJs = new("APM Agent RUM", "Application Performance Monitoring Agent for Real User Monitoring", VersioningSystemId.ApmAgentRumJs); + public static readonly ApplicabilityDefinition ApmAgentAndroid = new( + "APM Agent Android", + "Application Performance Monitoring Agent for Android", + VersioningSystemId.ApmAgentAndroid + ); + public static readonly ApplicabilityDefinition ApmAgentDotnet = new( + "APM Agent .NET", + "Application Performance Monitoring Agent for .NET", + VersioningSystemId.ApmAgentDotnet + ); + public static readonly ApplicabilityDefinition ApmAgentGo = new( + "APM Agent Go", + "Application Performance Monitoring Agent for Go", + VersioningSystemId.ApmAgentGo + ); + public static readonly ApplicabilityDefinition ApmAgentIos = new( + "APM Agent iOS", + "Application Performance Monitoring Agent for iOS", + VersioningSystemId.ApmAgentIos + ); + public static readonly ApplicabilityDefinition ApmAgentJava = new( + "APM Agent Java", + "Application Performance Monitoring Agent for Java", + VersioningSystemId.ApmAgentJava + ); + public static readonly ApplicabilityDefinition ApmAgentNode = new( + "APM Agent Node.js", + "Application Performance Monitoring Agent for Node.js", + VersioningSystemId.ApmAgentNode + ); + public static readonly ApplicabilityDefinition ApmAgentPhp = new( + "APM Agent PHP", + "Application Performance Monitoring Agent for PHP", + VersioningSystemId.ApmAgentPhp + ); + public static readonly ApplicabilityDefinition ApmAgentPython = new( + "APM Agent Python", + "Application Performance Monitoring Agent for Python", + VersioningSystemId.ApmAgentPython + ); + public static readonly ApplicabilityDefinition ApmAgentRuby = new( + "APM Agent Ruby", + "Application Performance Monitoring Agent for Ruby", + VersioningSystemId.ApmAgentRuby + ); + public static readonly ApplicabilityDefinition ApmAgentRumJs = new( + "APM Agent RUM", + "Application Performance Monitoring Agent for Real User Monitoring", + VersioningSystemId.ApmAgentRumJs + ); // Generic product public static readonly ApplicabilityDefinition Product = new("", "", VersioningSystemId.All); - } diff --git a/src/Elastic.Markdown/Myst/Components/ApplicabilityRenderer.cs b/src/Elastic.Markdown/Myst/Components/ApplicabilityRenderer.cs index baace52693..f330020567 100644 --- a/src/Elastic.Markdown/Myst/Components/ApplicabilityRenderer.cs +++ b/src/Elastic.Markdown/Myst/Components/ApplicabilityRenderer.cs @@ -45,13 +45,13 @@ public record ApplicabilityRenderData( public static ApplicabilityRenderData RenderApplicability( IReadOnlyCollection applicabilities, ApplicabilityMappings.ApplicabilityDefinition applicabilityDefinition, - VersioningSystem versioningSystem) + VersioningSystem versioningSystem + ) { var allApplications = new AppliesCollection([.. applicabilities]); // Sort by version (highest first), then by lifecycle priority as tiebreaker - var sortedApplicabilities = applicabilities - .OrderByDescending(a => a.Version?.Min ?? ZeroVersion.Instance) + var sortedApplicabilities = applicabilities.OrderByDescending(a => a.Version?.Min ?? ZeroVersion.Instance) .ThenBy(a => ProductLifecycleInfo.GetOrder(a.Lifecycle)) .ToList(); @@ -70,8 +70,7 @@ public static ApplicabilityRenderData RenderApplicability( firstApplicability ??= applicability; // If this candidate has displayable data, use it - if (!string.IsNullOrEmpty(candidateBadgeData.BadgeLifecycleText) || - !string.IsNullOrEmpty(candidateBadgeData.Version)) + if (!string.IsNullOrEmpty(candidateBadgeData.BadgeLifecycleText) || !string.IsNullOrEmpty(candidateBadgeData.Version)) { badgeData = candidateBadgeData; break; @@ -84,14 +83,17 @@ public static ApplicabilityRenderData RenderApplicability( if (badgeData is null && firstBadgeData is not null && firstApplicability is not null && versioningSystem.IsVersioned()) { var versionSpec = firstApplicability.Version; - var isFutureVersion = versionSpec is not null && versionSpec != AllVersionsSpec.Instance && versionSpec.Min > versioningSystem.Current; + var isFutureVersion = versionSpec is not null + && versionSpec != AllVersionsSpec.Instance + && versionSpec.Min > versioningSystem.Current; if (isFutureVersion) { - var previousLifecycle = sortedApplicabilities.FirstOrDefault(a => - a != firstApplicability && - (a.Version is null || a.Version == AllVersionsSpec.Instance || - a.Version.Min <= versioningSystem.Current)); + var previousLifecycle = sortedApplicabilities.FirstOrDefault( + a => + a != firstApplicability && + (a.Version is null || a.Version == AllVersionsSpec.Instance || a.Version.Min <= versioningSystem.Current) + ); if (previousLifecycle is not null) badgeData = GetBadgeData(previousLifecycle, versioningSystem, allApplications); @@ -132,10 +134,7 @@ public static ApplicabilityRenderData RenderApplicability( /// /// Gets the badge display data for a single applicability (used internally for badge rendering decisions). /// - private static BadgeData GetBadgeData( - Applicability applicability, - VersioningSystem versioningSystem, - AppliesCollection allApplications) + private static BadgeData GetBadgeData(Applicability applicability, VersioningSystem versioningSystem, AppliesCollection allApplications) { var lifecycleClass = applicability.GetLifeCycleName().ToLowerInvariant().Replace(" ", "-"); var badgeLifecycleText = BuildBadgeLifecycleText(applicability, versioningSystem, allApplications); @@ -147,8 +146,10 @@ private static BadgeData GetBadgeData( var showVersion = !string.IsNullOrEmpty(versionDisplay); // Special handling for Removed lifecycle - don't show + suffix - if (applicability is { Lifecycle: ProductLifecycle.Removed, Version.Kind: VersionSpecKind.GreaterThanOrEqual } && - !string.IsNullOrEmpty(versionDisplay)) + if ( + applicability is { Lifecycle: ProductLifecycle.Removed, Version.Kind: VersionSpecKind.GreaterThanOrEqual } && + !string.IsNullOrEmpty(versionDisplay) + ) { versionDisplay = versionDisplay.TrimEnd('+'); } @@ -175,21 +176,23 @@ bool ShowVersion private static PopoverData BuildPopoverData( IReadOnlyCollection applicabilities, ApplicabilityMappings.ApplicabilityDefinition applicabilityDefinition, - VersioningSystem versioningSystem) + VersioningSystem versioningSystem + ) { var productInfo = ProductDescriptions.GetProductInfo(versioningSystem.Id); var productName = GetPlainProductName(applicabilityDefinition.DisplayName); // Availability section - collect items from all applicabilities // Order by version descending (most recent/future first, then going backwards) - var orderedApplicabilities = applicabilities - .OrderByDescending(a => a.Version?.Min ?? ZeroVersion.Instance); + var orderedApplicabilities = applicabilities.OrderByDescending(a => a.Version?.Min ?? ZeroVersion.Instance); var showVersionNote = productInfo is { IncludeVersionNote: true } && versioningSystem.IsVersioned(); return new PopoverData( ProductDescription: productInfo?.Description, - AvailabilityItems: orderedApplicabilities.Select(applicability => BuildAvailabilityItem(applicability, versioningSystem, productName, applicabilities.Count)).OfType().ToArray(), + AvailabilityItems: orderedApplicabilities.Select( + applicability => BuildAvailabilityItem(applicability, versioningSystem, productName, applicabilities.Count) + ).OfType().ToArray(), AdditionalInfo: productInfo?.AdditionalAvailabilityInfo, ShowVersionNote: showVersionNote, VersionNote: showVersionNote ? ProductDescriptions.VersionNote : null @@ -204,7 +207,8 @@ private static PopoverData BuildPopoverData( Applicability applicability, VersioningSystem versioningSystem, string productName, - int lifecycleCount) + int lifecycleCount + ) { var availabilityText = GenerateAvailabilityText(applicability, versioningSystem, lifecycleCount); @@ -212,26 +216,16 @@ private static PopoverData BuildPopoverData( return null; var isReleased = IsVersionReleased(applicability, versioningSystem); - var lifecycleDescription = LifecycleDescriptions.GetDescriptionWithProduct( - applicability.Lifecycle, - isReleased, - productName - ); + var lifecycleDescription = LifecycleDescriptions.GetDescriptionWithProduct(applicability.Lifecycle, isReleased, productName); - return new PopoverAvailabilityItem( - Text: availabilityText, - LifecycleDescription: lifecycleDescription - ); + return new PopoverAvailabilityItem(Text: availabilityText, LifecycleDescription: lifecycleDescription); } /// /// Generates the dynamic availability text based on version type, lifecycle, release status, and lifecycle count. /// Returns null if the item should not be added to the availability list. /// - private static string? GenerateAvailabilityText( - Applicability applicability, - VersioningSystem versioningSystem, - int lifecycleCount) + private static string? GenerateAvailabilityText(Applicability applicability, VersioningSystem versioningSystem, int lifecycleCount) { var lifecycle = applicability.Lifecycle; var versionSpec = applicability.Version; @@ -256,9 +250,7 @@ private static PopoverData BuildPopoverData( var showMinPatch = versionSpec.ShowMinPatch; var showMaxPatch = versionSpec.ShowMaxPatch; var minVersion = showMinPatch ? $"{min.Major}.{min.Minor}.{min.Patch}" : $"{min.Major}.{min.Minor}"; - var maxVersion = max is not null - ? (showMaxPatch ? $"{max.Major}.{max.Minor}.{max.Patch}" : $"{max.Major}.{max.Minor}") - : null; + var maxVersion = max is not null ? (showMaxPatch ? $"{max.Major}.{max.Minor}.{max.Patch}" : $"{max.Major}.{max.Minor}") : null; var isMinReleased = min <= versioningSystem.Current; var isMaxReleased = max is not null && max <= versioningSystem.Current; @@ -266,13 +258,11 @@ private static PopoverData BuildPopoverData( { // Greater than or equal (x.x+, x.x, x.x.x+, x.x.x) VersionSpecKind.GreaterThanOrEqual => GenerateGteAvailabilityText(lifecycle, minVersion, isMinReleased, lifecycleCount), - // Range (x.x-y.y, x.x.x-y.y.y) - VersionSpecKind.Range => GenerateRangeAvailabilityText(lifecycle, minVersion, maxVersion!, isMinReleased, isMaxReleased, lifecycleCount), - + VersionSpecKind.Range => + GenerateRangeAvailabilityText(lifecycle, minVersion, maxVersion!, isMinReleased, isMaxReleased, lifecycleCount), // Exact (=x.x, =x.x.x) VersionSpecKind.Exact => GenerateExactAvailabilityText(lifecycle, minVersion, isMinReleased, lifecycleCount), - _ => null }; } @@ -297,6 +287,7 @@ private static PopoverData BuildPopoverData( ProductLifecycle.Removed => "Planned for removal", ProductLifecycle.Unavailable when lifecycleCount == 1 => "Unavailable", _ when lifecycleCount >= 2 => null, // Do not add to availability list + _ => "Planned" }; } @@ -305,7 +296,13 @@ private static PopoverData BuildPopoverData( /// Generates availability text for range version type. ///
private static string? GenerateRangeAvailabilityText( - ProductLifecycle lifecycle, string minVersion, string maxVersion, bool isMinReleased, bool isMaxReleased, int lifecycleCount) + ProductLifecycle lifecycle, + string minVersion, + string maxVersion, + bool isMinReleased, + bool isMaxReleased, + int lifecycleCount + ) { if (isMaxReleased) { @@ -335,6 +332,7 @@ private static PopoverData BuildPopoverData( ProductLifecycle.Removed => "Planned for removal", ProductLifecycle.Unavailable => null, _ when lifecycleCount >= 2 => null, // Do not add to availability list + _ => "Planned" }; } @@ -361,6 +359,7 @@ private static PopoverData BuildPopoverData( ProductLifecycle.Removed => "Planned for removal", ProductLifecycle.Unavailable => null, _ when lifecycleCount >= 2 => null, // Do not add to availability list + _ => "Planned" }; } @@ -368,8 +367,7 @@ private static PopoverData BuildPopoverData( /// /// Gets the plain product name without HTML entities for use in text substitution. /// - private static string GetPlainProductName(string displayName) => - displayName.Replace(" ", " "); + private static string GetPlainProductName(string displayName) => displayName.Replace(" ", " "); /// /// Determines if a version should be considered released for lifecycle description purposes @@ -391,7 +389,8 @@ private static bool IsVersionReleased(Applicability applicability, VersioningSys private static string BuildBadgeLifecycleText( Applicability applicability, VersioningSystem versioningSystem, - AppliesCollection allApplications) + AppliesCollection allApplications + ) { var badgeText = ""; var versionSpec = applicability.Version; @@ -403,8 +402,8 @@ private static string BuildBadgeLifecycleText( // Determine if we should show "Planned" badge var shouldShowPlanned = (versionSpec.Kind == VersionSpecKind.GreaterThanOrEqual && !isMinReleased) - || (versionSpec.Kind == VersionSpecKind.Range && !isMaxReleased && !isMinReleased) - || (versionSpec.Kind == VersionSpecKind.Exact && !isMinReleased); + || (versionSpec.Kind == VersionSpecKind.Range && !isMaxReleased && !isMinReleased) + || (versionSpec.Kind == VersionSpecKind.Exact && !isMinReleased); // Check lifecycle count for "use previous lifecycle" logic if (shouldShowPlanned) @@ -457,32 +456,21 @@ private static string GetBadgeVersionText(VersionSpec? versionSpec, VersioningSy var maxReleased = max is not null && max <= versioningSystem.Current; // Helper to format version with or without patch - string FormatMinVersion() => showMinPatch - ? $"{min.Major}.{min.Minor}.{min.Patch}" - : $"{min.Major}.{min.Minor}"; + string FormatMinVersion() => showMinPatch ? $"{min.Major}.{min.Minor}.{min.Patch}" : $"{min.Major}.{min.Minor}"; - string FormatMaxVersion() => showMaxPatch - ? $"{max!.Major}.{max.Minor}.{max.Patch}" - : $"{max!.Major}.{max.Minor}"; + string FormatMaxVersion() => showMaxPatch ? $"{max!.Major}.{max.Minor}.{max.Patch}" : $"{max!.Major}.{max.Minor}"; return kind switch { - VersionSpecKind.GreaterThanOrEqual => minReleased - ? $"{FormatMinVersion()}+" - : string.Empty, - - VersionSpecKind.Range => maxReleased - ? min.Major == max!.Major && min.Minor == max.Minor && !showMinPatch && !showMaxPatch - ? $"{min.Major}.{min.Minor}" // Same major.minor and no explicit patch, so just show the version once - : $"{FormatMinVersion()}-{FormatMaxVersion()}" - : minReleased - ? $"{FormatMinVersion()}+" - : string.Empty, - - VersionSpecKind.Exact => minReleased - ? FormatMinVersion() - : string.Empty, - + VersionSpecKind.GreaterThanOrEqual => minReleased ? $"{FormatMinVersion()}+" : string.Empty, + VersionSpecKind.Range => + maxReleased + ? min.Major == max!.Major && min.Minor == max.Minor && !showMinPatch && !showMaxPatch + ? $"{min.Major}.{min.Minor}" // Same major.minor and no explicit patch, so just show the version once + + : $"{FormatMinVersion()}-{FormatMaxVersion()}" + : minReleased ? $"{FormatMinVersion()}+" : string.Empty, + VersionSpecKind.Exact => minReleased ? FormatMinVersion() : string.Empty, _ => string.Empty }; } diff --git a/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs b/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs index c7b858d396..ffd9d566f0 100644 --- a/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs +++ b/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs @@ -73,7 +73,6 @@ public class ApplicableToViewModel [p => p.ApmAgentRumJs] = ApplicabilityMappings.ApmAgentRumJs }; - public IReadOnlyCollection GetApplicabilityItems() { var rawItems = BadgePlacement switch @@ -92,9 +91,11 @@ private List CollectCombinedRaw() if (AppliesTo.Serverless is not null) { - rawItems.AddRange(AppliesTo.Serverless.AllProjects is not null - ? CollectFromCollection(AppliesTo.Serverless.AllProjects, ApplicabilityMappings.Serverless) - : CollectFromMappings(AppliesTo.Serverless, ServerlessMappings)); + rawItems.AddRange( + AppliesTo.Serverless.AllProjects is not null + ? CollectFromCollection(AppliesTo.Serverless.AllProjects, ApplicabilityMappings.Serverless) + : CollectFromMappings(AppliesTo.Serverless, ServerlessMappings) + ); } if (AppliesTo.Stack is not null) @@ -131,9 +132,11 @@ private List CollectSupportedOnRaw() if (AppliesTo.Serverless is not null) { - rawItems.AddRange(AppliesTo.Serverless.AllProjects is not null - ? CollectFromCollection(AppliesTo.Serverless.AllProjects, ApplicabilityMappings.Serverless) - : CollectFromMappings(AppliesTo.Serverless, ServerlessMappings)); + rawItems.AddRange( + AppliesTo.Serverless.AllProjects is not null + ? CollectFromCollection(AppliesTo.Serverless.AllProjects, ApplicabilityMappings.Serverless) + : CollectFromMappings(AppliesTo.Serverless, ServerlessMappings) + ); } if (AppliesTo.Deployment is not null) @@ -142,17 +145,12 @@ private List CollectSupportedOnRaw() if (AppliesTo.ProductApplicability is not null) rawItems.AddRange(CollectFromMappings(AppliesTo.ProductApplicability, ProductMappings)); - var noExplicitSupportedOn = - AppliesTo.Deployment is null && - AppliesTo.Serverless is null && - AppliesTo.ProductApplicability is null; + var noExplicitSupportedOn = AppliesTo.Deployment is null && AppliesTo.Serverless is null && AppliesTo.ProductApplicability is null; if (rawItems.Count == 0 && noExplicitSupportedOn && AppliesTo.Stack is not null) rawItems.AddRange(CollectFromCollection(AppliesTo.Stack, ApplicabilityMappings.Self)); - return rawItems - .Where(i => i.Applicability.Lifecycle != ProductLifecycle.Unavailable) - .ToList(); + return rawItems.Where(i => i.Applicability.Lifecycle != ProductLifecycle.Unavailable).ToList(); } private static bool IsGenericGa(AppliesCollection collection) @@ -161,8 +159,8 @@ private static bool IsGenericGa(AppliesCollection collection) return false; var applicability = collection.First(); - return applicability.Lifecycle == ProductLifecycle.GenerallyAvailable - && (applicability.Version is null || applicability.Version == AllVersionsSpec.Instance); + return applicability.Lifecycle == ProductLifecycle.GenerallyAvailable && + (applicability.Version is null || applicability.Version == AllVersionsSpec.Instance); } /// @@ -170,19 +168,24 @@ private static bool IsGenericGa(AppliesCollection collection) /// private static IEnumerable CollectFromCollection( AppliesCollection collection, - ApplicabilityMappings.ApplicabilityDefinition applicabilityDefinition) => - collection.Select(applicability => new RawApplicabilityItem( - Key: applicabilityDefinition.Key, - Applicability: applicability, - ApplicabilityDefinition: applicabilityDefinition - )); + ApplicabilityMappings.ApplicabilityDefinition applicabilityDefinition + ) => + collection.Select( + applicability => + new RawApplicabilityItem( + Key: applicabilityDefinition.Key, + Applicability: applicability, + ApplicabilityDefinition: applicabilityDefinition + ) + ); /// /// Collects raw applicability items from mapped collections. /// private static IReadOnlyCollection CollectFromMappings( T source, - Dictionary, ApplicabilityMappings.ApplicabilityDefinition> mappings) + Dictionary, ApplicabilityMappings.ApplicabilityDefinition> mappings + ) { var items = new List(); @@ -200,30 +203,25 @@ private static IReadOnlyCollection CollectFromMappings( /// Groups raw items by key and renders each group using the unified renderer. /// private IEnumerable RenderGroupedItems(IReadOnlyCollection rawItems) => - rawItems - .GroupBy(item => item.Key) - .Select(group => - { - var items = group.ToList(); - var applicabilityDefinition = items.First().ApplicabilityDefinition; - var versioningSystem = VersionsConfig.GetVersioningSystem(applicabilityDefinition.VersioningSystemId); - var allApplicabilities = items.Select(i => i.Applicability).ToArray(); - - var renderData = ApplicabilityRenderer.RenderApplicability( - allApplicabilities, - applicabilityDefinition, - versioningSystem); - - // Select the closest version to current as the primary display - var primaryApplicability = ApplicabilitySelector.GetPrimaryApplicability(allApplicabilities, versioningSystem.Current); - - return new ApplicabilityItem( - Key: items.First().Key, - PrimaryApplicability: primaryApplicability, - RenderData: renderData, - ApplicabilityDefinition: applicabilityDefinition - ); - }); + rawItems.GroupBy(item => item.Key).Select(group => + { + var items = group.ToList(); + var applicabilityDefinition = items.First().ApplicabilityDefinition; + var versioningSystem = VersionsConfig.GetVersioningSystem(applicabilityDefinition.VersioningSystemId); + var allApplicabilities = items.Select(i => i.Applicability).ToArray(); + + var renderData = ApplicabilityRenderer.RenderApplicability(allApplicabilities, applicabilityDefinition, versioningSystem); + + // Select the closest version to current as the primary display + var primaryApplicability = ApplicabilitySelector.GetPrimaryApplicability(allApplicabilities, versioningSystem.Current); + + return new ApplicabilityItem( + Key: items.First().Key, + PrimaryApplicability: primaryApplicability, + RenderData: renderData, + ApplicabilityDefinition: applicabilityDefinition + ); + }); /// /// Intermediate representation before rendering. diff --git a/src/Elastic.Markdown/Myst/Components/LifecycleDescriptions.cs b/src/Elastic.Markdown/Myst/Components/LifecycleDescriptions.cs index 2973dfd276..d20b4d2b99 100644 --- a/src/Elastic.Markdown/Myst/Components/LifecycleDescriptions.cs +++ b/src/Elastic.Markdown/Myst/Components/LifecycleDescriptions.cs @@ -41,40 +41,29 @@ public static class LifecycleDescriptions "This functionality is in technical preview and is ready for evaluation. Use with caution in production; it is not recommended for mission-critical workloads. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features. Specific Support terms apply.", [(ProductLifecycle.TechnicalPreview, false)] = "We plan to add this functionality in a future {product} update. Subject to changes.", - // Experimental [(ProductLifecycle.Experimental, true)] = "This functionality is experimental and is not ready for production usage. Experimental features may change or be removed at any time. Elastic will work to fix any issues, but experimental features are not subject to the support SLA of official GA features. Specific Support terms apply.", - [(ProductLifecycle.Experimental, false)] = - "We plan to add this functionality in a future {product} update. Subject to changes.", - + [(ProductLifecycle.Experimental, false)] = "We plan to add this functionality in a future {product} update. Subject to changes.", // Beta [(ProductLifecycle.Beta, true)] = "This functionality is in beta and is not ready for production usage. For beta features, the design and code is less mature than official GA features and is being provided as-is with no warranties. Beta features are not subject to the support SLA of official GA features. Specific Support terms apply.", - [(ProductLifecycle.Beta, false)] = - "We plan to add this functionality in a future {product} update. Subject to changes.", - + [(ProductLifecycle.Beta, false)] = "We plan to add this functionality in a future {product} update. Subject to changes.", // GA - [(ProductLifecycle.GenerallyAvailable, true)] = - "This functionality is generally available and ready for production usage.", + [(ProductLifecycle.GenerallyAvailable, true)] = "This functionality is generally available and ready for production usage.", [(ProductLifecycle.GenerallyAvailable, false)] = "We plan to add this functionality in a future {product} update. Subject to changes.", - // Deprecated [(ProductLifecycle.Deprecated, true)] = "This functionality is deprecated. You can still use it, but it'll be removed in a future {product} update.", [(ProductLifecycle.Deprecated, false)] = "This functionality is planned to be deprecated in a future {product} update. Subject to changes.", - // Removed [(ProductLifecycle.Removed, true)] = "This functionality was removed. You can no longer use it if you're running on this version or a later one.", [(ProductLifecycle.Removed, false)] = "This functionality is planned to be removed in an upcoming {product} update. Subject to changes.", - // Unavailable - [(ProductLifecycle.Unavailable, true)] = - "This functionality is not available in {product}." + [(ProductLifecycle.Unavailable, true)] = "This functionality is not available in {product}." }; } - diff --git a/src/Elastic.Markdown/Myst/Components/ProductDescriptions.cs b/src/Elastic.Markdown/Myst/Components/ProductDescriptions.cs index c0c129c42c..a3fc5f75ac 100644 --- a/src/Elastic.Markdown/Myst/Components/ProductDescriptions.cs +++ b/src/Elastic.Markdown/Myst/Components/ProductDescriptions.cs @@ -17,11 +17,7 @@ public static class ProductDescriptions /// The product description shown at the top of the popover (required). /// Additional availability information shown near the bottom of the popover (optional). /// Whether to include the version note at the bottom of the popover. - public record ProductInfo( - string Description, - string? AdditionalAvailabilityInfo, - bool IncludeVersionNote - ); + public record ProductInfo(string Description, string? AdditionalAvailabilityInfo, bool IncludeVersionNote); /// /// The version note text shown at the bottom of versioned product popovers. @@ -29,169 +25,190 @@ bool IncludeVersionNote public const string VersionNote = "This documentation corresponds to the latest patch available for each minor version. If you're not using the latest patch, check the release notes for changes."; - public static ProductInfo? GetProductInfo(VersioningSystemId versioningSystemId) => - Descriptions.GetValueOrDefault(versioningSystemId); + public static ProductInfo? GetProductInfo(VersioningSystemId versioningSystemId) => Descriptions.GetValueOrDefault(versioningSystemId); private static readonly Dictionary Descriptions = new() { // Stack - [VersioningSystemId.Stack] = new ProductInfo( - Description: "The Elastic Stack includes Elastic's core products such as Elasticsearch, Kibana, Logstash, and Beats.", - AdditionalAvailabilityInfo: "Unless stated otherwise on the page, this functionality is available when your Elastic Stack is deployed on Elastic Cloud Hosted, Elastic Cloud Enterprise, Elastic Cloud on Kubernetes, and self-managed environments.", - IncludeVersionNote: true - ), - + [VersioningSystemId.Stack] = + new ProductInfo( + Description: "The Elastic Stack includes Elastic's core products such as Elasticsearch, Kibana, Logstash, and Beats.", + AdditionalAvailabilityInfo: "Unless stated otherwise on the page, this functionality is available when your Elastic Stack is deployed on Elastic Cloud Hosted, Elastic Cloud Enterprise, Elastic Cloud on Kubernetes, and self-managed environments.", + IncludeVersionNote: true + ), // Serverless - [VersioningSystemId.Serverless] = new ProductInfo( - Description: "Elastic Cloud Serverless projects are autoscaled environments, fully managed by Elastic and available on Elastic Cloud.", - AdditionalAvailabilityInfo: "Serverless interfaces and procedures might differ from classic Elastic Stack deployments.", - IncludeVersionNote: false - ), - + [VersioningSystemId.Serverless] = + new ProductInfo( + Description: "Elastic Cloud Serverless projects are autoscaled environments, fully managed by Elastic and available on Elastic Cloud.", + AdditionalAvailabilityInfo: "Serverless interfaces and procedures might differ from classic Elastic Stack deployments.", + IncludeVersionNote: false + ), // Serverless Project Types - [VersioningSystemId.ElasticsearchProject] = new ProductInfo( - Description: "Elastic Cloud Serverless projects are autoscaled environments, fully managed by Elastic and available on Elastic Cloud.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: false - ), - [VersioningSystemId.ObservabilityProject] = new ProductInfo( - Description: "Elastic Cloud Serverless projects are autoscaled environments, fully managed by Elastic and available on Elastic Cloud.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: false - ), - [VersioningSystemId.SecurityProject] = new ProductInfo( - Description: "Elastic Cloud Serverless projects are autoscaled environments, fully managed by Elastic and available on Elastic Cloud.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: false - ), - + [VersioningSystemId.ElasticsearchProject] = + new ProductInfo( + Description: "Elastic Cloud Serverless projects are autoscaled environments, fully managed by Elastic and available on Elastic Cloud.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: false + ), + [VersioningSystemId.ObservabilityProject] = + new ProductInfo( + Description: "Elastic Cloud Serverless projects are autoscaled environments, fully managed by Elastic and available on Elastic Cloud.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: false + ), + [VersioningSystemId.SecurityProject] = + new ProductInfo( + Description: "Elastic Cloud Serverless projects are autoscaled environments, fully managed by Elastic and available on Elastic Cloud.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: false + ), // Deployment Types - [VersioningSystemId.Ess] = new ProductInfo( - Description: "Elastic Cloud Hosted lets you manage and configure one or more deployments of the versioned Elastic Stack, hosted on Elastic Cloud.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: false - ), - [VersioningSystemId.Ece] = new ProductInfo( - Description: "Elastic Cloud Enterprise is a self-managed orchestration platform for deploying and managing the Elastic Stack at scale.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: true - ), - [VersioningSystemId.Eck] = new ProductInfo( - Description: "Elastic Cloud on Kubernetes extends Kubernetes orchestration capabilities to allow you to deploy and manage components of the Elastic Stack.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: true - ), - [VersioningSystemId.Self] = new ProductInfo( - Description: "Self-managed deployments are Elastic Stack deployments managed without the assistance of an orchestrator.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: true - ), - + [VersioningSystemId.Ess] = + new ProductInfo( + Description: "Elastic Cloud Hosted lets you manage and configure one or more deployments of the versioned Elastic Stack, hosted on Elastic Cloud.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: false + ), + [VersioningSystemId.Ece] = + new ProductInfo( + Description: "Elastic Cloud Enterprise is a self-managed orchestration platform for deploying and managing the Elastic Stack at scale.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: true + ), + [VersioningSystemId.Eck] = + new ProductInfo( + Description: "Elastic Cloud on Kubernetes extends Kubernetes orchestration capabilities to allow you to deploy and manage components of the Elastic Stack.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: true + ), + [VersioningSystemId.Self] = + new ProductInfo( + Description: "Self-managed deployments are Elastic Stack deployments managed without the assistance of an orchestrator.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: true + ), // Products - [VersioningSystemId.Ecctl] = new ProductInfo( - Description: "ECCTL is the command line interface for the Elastic Cloud and Elastic Cloud Enterprise APIs.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: true - ), - [VersioningSystemId.Curator] = new ProductInfo( - Description: "Curator is a tool that helps you to manage your Elasticsearch indices and snapshots to save space and improve performance.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: true - ), - + [VersioningSystemId.Ecctl] = + new ProductInfo( + Description: "ECCTL is the command line interface for the Elastic Cloud and Elastic Cloud Enterprise APIs.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: true + ), + [VersioningSystemId.Curator] = + new ProductInfo( + Description: "Curator is a tool that helps you to manage your Elasticsearch indices and snapshots to save space and improve performance.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: true + ), // APM Agents - [VersioningSystemId.ApmAgentDotnet] = new ProductInfo( - Description: "The Elastic APM .NET agent enables you to trace the execution of operations in your .NET applications, sending performance metrics and errors to the Elastic APM server.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: true - ), - [VersioningSystemId.ApmAgentGo] = new ProductInfo( - Description: "The Elastic APM Go agent enables you to trace the execution of operations in your Go applications, sending performance metrics and errors to the Elastic APM Server.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: true - ), - [VersioningSystemId.ApmAgentJava] = new ProductInfo( - Description: "The Elastic APM Java agent enables you to trace the execution of operations in your Java applications, sending performance metrics and errors to the Elastic APM Server.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: true - ), - [VersioningSystemId.ApmAgentNode] = new ProductInfo( - Description: "The Elastic APM Node.js agent enables you to trace the execution of operations in your Node.js applications, sending performance metrics and errors to the Elastic APM Server.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: true - ), - [VersioningSystemId.ApmAgentPhp] = new ProductInfo( - Description: "The Elastic APM PHP agent enables you to trace the execution of operations in your PHP applications, sending performance metrics and errors to the Elastic APM Server.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: true - ), - [VersioningSystemId.ApmAgentPython] = new ProductInfo( - Description: "The Elastic APM Python agent enables you to trace the execution of operations in your Python applications, sending performance metrics and errors to the Elastic APM Server.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: true - ), - [VersioningSystemId.ApmAgentRuby] = new ProductInfo( - Description: "The Elastic APM Ruby agent enables you to trace the execution of operations in your Ruby applications, sending performance metrics and errors to the Elastic APM Server.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: true - ), - [VersioningSystemId.ApmAgentRumJs] = new ProductInfo( - Description: "The Elastic APM RUM JavaScript agent enables you to trace the execution of operations in your web applications, sending performance metrics and errors to the Elastic APM Server.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: true - ), - + [VersioningSystemId.ApmAgentDotnet] = + new ProductInfo( + Description: "The Elastic APM .NET agent enables you to trace the execution of operations in your .NET applications, sending performance metrics and errors to the Elastic APM server.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: true + ), + [VersioningSystemId.ApmAgentGo] = + new ProductInfo( + Description: "The Elastic APM Go agent enables you to trace the execution of operations in your Go applications, sending performance metrics and errors to the Elastic APM Server.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: true + ), + [VersioningSystemId.ApmAgentJava] = + new ProductInfo( + Description: "The Elastic APM Java agent enables you to trace the execution of operations in your Java applications, sending performance metrics and errors to the Elastic APM Server.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: true + ), + [VersioningSystemId.ApmAgentNode] = + new ProductInfo( + Description: "The Elastic APM Node.js agent enables you to trace the execution of operations in your Node.js applications, sending performance metrics and errors to the Elastic APM Server.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: true + ), + [VersioningSystemId.ApmAgentPhp] = + new ProductInfo( + Description: "The Elastic APM PHP agent enables you to trace the execution of operations in your PHP applications, sending performance metrics and errors to the Elastic APM Server.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: true + ), + [VersioningSystemId.ApmAgentPython] = + new ProductInfo( + Description: "The Elastic APM Python agent enables you to trace the execution of operations in your Python applications, sending performance metrics and errors to the Elastic APM Server.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: true + ), + [VersioningSystemId.ApmAgentRuby] = + new ProductInfo( + Description: "The Elastic APM Ruby agent enables you to trace the execution of operations in your Ruby applications, sending performance metrics and errors to the Elastic APM Server.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: true + ), + [VersioningSystemId.ApmAgentRumJs] = + new ProductInfo( + Description: "The Elastic APM RUM JavaScript agent enables you to trace the execution of operations in your web applications, sending performance metrics and errors to the Elastic APM Server.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: true + ), // Elastic OpenTelemetry Products - [VersioningSystemId.EdotCollector] = new ProductInfo( - Description: "Elastic Agent can run in OpenTelemetry mode to retrieve traces, metrics, and logs from your infrastructure and applications, and forward them to Elastic Observability.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: true - ), - [VersioningSystemId.EdotIos] = new ProductInfo( - Description: "The Elastic Distribution of OpenTelemetry (EDOT) iOS SDK collects performance metrics, traces, and logs in OpenTelemetry format, and sends them to Elastic Observability.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: true - ), - [VersioningSystemId.EdotAndroid] = new ProductInfo( - Description: "The Elastic Distribution of OpenTelemetry (EDOT) Android SDK collects performance metrics, traces, and logs in OpenTelemetry format, and sends them to Elastic Observability.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: true - ), - [VersioningSystemId.EdotDotnet] = new ProductInfo( - Description: "The Elastic Distribution of OpenTelemetry (EDOT) .NET SDK collects performance metrics, traces, and logs in OpenTelemetry format, and sends them to Elastic Observability.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: true - ), - [VersioningSystemId.EdotJava] = new ProductInfo( - Description: "The Elastic Distribution of OpenTelemetry (EDOT) Java SDK collects performance metrics, traces, and logs in OpenTelemetry format, and sends them to Elastic Observability.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: true - ), - [VersioningSystemId.EdotNode] = new ProductInfo( - Description: "The Elastic Distribution of OpenTelemetry (EDOT) Node.js SDK collects performance metrics, traces, and logs in OpenTelemetry format, and sends them to Elastic Observability.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: true - ), - [VersioningSystemId.EdotBrowser] = new ProductInfo( - Description: "The Elastic Distribution of OpenTelemetry (EDOT) Browser SDK (RUM) collects performance metrics, traces, and logs from web applications in OpenTelemetry format, and sends them to Elastic Observability.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: true - ), - [VersioningSystemId.EdotPhp] = new ProductInfo( - Description: "The Elastic Distribution of OpenTelemetry (EDOT) PHP SDK collects performance metrics, traces, and logs in OpenTelemetry format, and sends them to Elastic Observability.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: true - ), - [VersioningSystemId.EdotPython] = new ProductInfo( - Description: "The Elastic Distribution of OpenTelemetry (EDOT) Python SDK collects performance metrics, traces, and logs in OpenTelemetry format, and sends them to Elastic Observability.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: true - ), - [VersioningSystemId.EdotCfAws] = new ProductInfo( - Description: "The Elastic Cloud Forwarder allows you to collect and send your telemetry data to Elastic Observability from AWS, GCP, and Azure.", - AdditionalAvailabilityInfo: null, - IncludeVersionNote: true - ), + [VersioningSystemId.EdotCollector] = + new ProductInfo( + Description: "Elastic Agent can run in OpenTelemetry mode to retrieve traces, metrics, and logs from your infrastructure and applications, and forward them to Elastic Observability.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: true + ), + [VersioningSystemId.EdotIos] = + new ProductInfo( + Description: "The Elastic Distribution of OpenTelemetry (EDOT) iOS SDK collects performance metrics, traces, and logs in OpenTelemetry format, and sends them to Elastic Observability.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: true + ), + [VersioningSystemId.EdotAndroid] = + new ProductInfo( + Description: "The Elastic Distribution of OpenTelemetry (EDOT) Android SDK collects performance metrics, traces, and logs in OpenTelemetry format, and sends them to Elastic Observability.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: true + ), + [VersioningSystemId.EdotDotnet] = + new ProductInfo( + Description: "The Elastic Distribution of OpenTelemetry (EDOT) .NET SDK collects performance metrics, traces, and logs in OpenTelemetry format, and sends them to Elastic Observability.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: true + ), + [VersioningSystemId.EdotJava] = + new ProductInfo( + Description: "The Elastic Distribution of OpenTelemetry (EDOT) Java SDK collects performance metrics, traces, and logs in OpenTelemetry format, and sends them to Elastic Observability.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: true + ), + [VersioningSystemId.EdotNode] = + new ProductInfo( + Description: "The Elastic Distribution of OpenTelemetry (EDOT) Node.js SDK collects performance metrics, traces, and logs in OpenTelemetry format, and sends them to Elastic Observability.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: true + ), + [VersioningSystemId.EdotBrowser] = + new ProductInfo( + Description: "The Elastic Distribution of OpenTelemetry (EDOT) Browser SDK (RUM) collects performance metrics, traces, and logs from web applications in OpenTelemetry format, and sends them to Elastic Observability.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: true + ), + [VersioningSystemId.EdotPhp] = + new ProductInfo( + Description: "The Elastic Distribution of OpenTelemetry (EDOT) PHP SDK collects performance metrics, traces, and logs in OpenTelemetry format, and sends them to Elastic Observability.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: true + ), + [VersioningSystemId.EdotPython] = + new ProductInfo( + Description: "The Elastic Distribution of OpenTelemetry (EDOT) Python SDK collects performance metrics, traces, and logs in OpenTelemetry format, and sends them to Elastic Observability.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: true + ), + [VersioningSystemId.EdotCfAws] = + new ProductInfo( + Description: "The Elastic Cloud Forwarder allows you to collect and send your telemetry data to Elastic Observability from AWS, GCP, and Azure.", + AdditionalAvailabilityInfo: null, + IncludeVersionNote: true + ), }; } - diff --git a/src/Elastic.Markdown/Myst/Directives/AgentSkill/AgentSkillBlock.cs b/src/Elastic.Markdown/Myst/Directives/AgentSkill/AgentSkillBlock.cs index 333e0af4cf..e8cf6c4377 100644 --- a/src/Elastic.Markdown/Myst/Directives/AgentSkill/AgentSkillBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/AgentSkill/AgentSkillBlock.cs @@ -6,8 +6,7 @@ namespace Elastic.Markdown.Myst.Directives.AgentSkill; -public class AgentSkillBlock(DirectiveBlockParser parser, ParserContext context) - : DirectiveBlock(parser, context) +public class AgentSkillBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context) { public override string Directive => "agent-skill"; @@ -17,9 +16,7 @@ public class AgentSkillBlock(DirectiveBlockParser parser, ParserContext context) public string? RepoPrefix { get; private set; } - public string? InstallCommand => SkillName is not null && RepoPrefix is not null - ? $"npx skills add {RepoPrefix}@{SkillName}" - : null; + public string? InstallCommand => SkillName is not null && RepoPrefix is not null ? $"npx skills add {RepoPrefix}@{SkillName}" : null; public override void FinalizeAndValidate(ParserContext context) { diff --git a/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchBlock.cs b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchBlock.cs index 7c0c959393..b720062f96 100644 --- a/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchBlock.cs @@ -10,8 +10,7 @@ namespace Elastic.Markdown.Myst.Directives.AppliesSwitch; -public class AppliesSwitchBlock(DirectiveBlockParser parser, ParserContext context) - : DirectiveBlock(parser, context) +public class AppliesSwitchBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context) { public override string Directive => "applies-switch"; @@ -32,8 +31,10 @@ public int FindIndex() } } -public class AppliesItemBlock(DirectiveBlockParser parser, ParserContext context) - : DirectiveBlock(parser, context), IBlockTitle, IBlockAppliesTo +public class AppliesItemBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock( + parser, + context +), IBlockTitle, IBlockAppliesTo { public override string Directive => "applies-item"; diff --git a/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchViewModel.cs b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchViewModel.cs index 1b65b70874..fa96ef0ab0 100644 --- a/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchViewModel.cs @@ -5,4 +5,3 @@ namespace Elastic.Markdown.Myst.Directives.AppliesSwitch; public class AppliesSwitchViewModel : DirectiveViewModel; - diff --git a/src/Elastic.Markdown/Myst/Directives/AppliesTo/AppliesToDirective.cs b/src/Elastic.Markdown/Myst/Directives/AppliesTo/AppliesToDirective.cs index ceca05129b..de76fe2ae9 100644 --- a/src/Elastic.Markdown/Myst/Directives/AppliesTo/AppliesToDirective.cs +++ b/src/Elastic.Markdown/Myst/Directives/AppliesTo/AppliesToDirective.cs @@ -8,10 +8,7 @@ namespace Elastic.Markdown.Myst.Directives.AppliesTo; - -public class AppliesToDirective(BlockParser parser, ParserContext context) - : EnhancedCodeBlock(parser, context), IApplicableToElement +public class AppliesToDirective(BlockParser parser, ParserContext context) : EnhancedCodeBlock(parser, context), IApplicableToElement { public ApplicableTo? AppliesTo { get; set; } } - diff --git a/src/Elastic.Markdown/Myst/Directives/Button/ButtonBlock.cs b/src/Elastic.Markdown/Myst/Directives/Button/ButtonBlock.cs index 853c5202e1..95879e3b9c 100644 --- a/src/Elastic.Markdown/Myst/Directives/Button/ButtonBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Button/ButtonBlock.cs @@ -20,8 +20,7 @@ public class ButtonGroupBlock(DirectiveBlockParser parser, ParserContext context /// public string Align { get; private set; } = "left"; - public override void FinalizeAndValidate(ParserContext context) => - Align = Prop("align") ?? "left"; + public override void FinalizeAndValidate(ParserContext context) => Align = Prop("align") ?? "left"; } /// @@ -101,7 +100,9 @@ private void ValidateContent() // Check if content matches the link pattern if (!LinkPattern().IsMatch(content)) { - this.EmitError("Button directive must contain only a single Markdown link. Use: :::{button}\n[text](url)\n:::\nOr: :::{button}\n[text][ref]\n:::"); + this.EmitError( + "Button directive must contain only a single Markdown link. Use: :::{button}\n[text](url)\n:::\nOr: :::{button}\n[text][ref]\n:::" + ); } } diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs index 9f054ea8a4..aa67b3d8b0 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs @@ -212,7 +212,9 @@ public override void FinalizeAndValidate(ParserContext context) ConfigPath = Prop("config"); var productOpt = Prop("product"); if (!string.IsNullOrWhiteSpace(productOpt)) - this.EmitWarning("The :product: option is deprecated and has no effect. The directive does not apply rules.publish. Move type/area filtering to rules.bundle so it applies at bundle time."); + this.EmitWarning( + "The :product: option is deprecated and has no effect. The directive does not apply rules.publish. Move type/area filtering to rules.bundle so it applies at bundle time." + ); ProductId = productOpt; TypeFilter = ParseTypeFilter(); LoadConfiguration(); @@ -228,14 +230,13 @@ public override void FinalizeAndValidate(ParserContext context) { // :cdn: takes an explicit product, or may be valueless to infer the product from the // repository that holds the doc (the common case where the repo name is the product id). - var product = Prop("cdn") is { Length: > 0 } explicitProduct - ? explicitProduct.Trim() - : InferCdnProductFromRepository(); + var product = Prop("cdn") is { Length: > 0 } explicitProduct ? explicitProduct.Trim() : InferCdnProductFromRepository(); if (string.IsNullOrWhiteSpace(product)) { this.EmitError( - "The :cdn: product could not be inferred from the repository; specify it explicitly, e.g. ':cdn: elasticsearch'."); + "The :cdn: product could not be inferred from the repository; specify it explicitly, e.g. ':cdn: elasticsearch'." + ); return; } @@ -273,8 +274,7 @@ private ChangelogLinkVisibility ParseLinkVisibility() private ChangelogLinkVisibility EmitInvalidLinkVisibilityWarning(string value) { - this.EmitWarning( - $"Invalid :link-visibility: value '{value}'. Valid values are: auto, keep-links, hide-links. Using auto."); + this.EmitWarning($"Invalid :link-visibility: value '{value}'. Valid values are: auto, keep-links, hide-links. Using auto."); return ChangelogLinkVisibility.Auto; } @@ -297,7 +297,8 @@ private ChangelogDescriptionVisibility ParseDescriptionVisibility() private ChangelogDescriptionVisibility EmitInvalidDescriptionVisibilityWarning(string value) { this.EmitWarning( - $"Invalid :description-visibility: value '{value}'. Valid values are: auto, keep-descriptions, keep-highlight-descriptions, hide-descriptions. Using auto."); + $"Invalid :description-visibility: value '{value}'. Valid values are: auto, keep-descriptions, keep-highlight-descriptions, hide-descriptions. Using auto." + ); return ChangelogDescriptionVisibility.Auto; } @@ -327,13 +328,16 @@ private ChangelogTypeFilter ParseTypeFilter() private ChangelogTypeFilter EmitLegacyHighlightTypeWarning() { this.EmitWarning( - "Invalid :type: value 'highlight'. Highlights are controlled with :highlights: (not :type:). Using default behavior."); + "Invalid :type: value 'highlight'. Highlights are controlled with :highlights: (not :type:). Using default behavior." + ); return ChangelogTypeFilter.Default; } private ChangelogTypeFilter EmitInvalidTypeFilterWarning(string typeValue) { - this.EmitWarning($"Invalid :type: value '{typeValue}'. Valid values are: all, breaking-change, deprecation, known-issue. Using default behavior."); + this.EmitWarning( + $"Invalid :type: value '{typeValue}'. Valid values are: all, breaking-change, deprecation, known-issue. Using default behavior." + ); return ChangelogTypeFilter.Default; } @@ -373,7 +377,8 @@ private void ExtractBundlesFolderPath() return; } - var bundles = Build.ReadFileSystem.Directory + var bundles = Build.ReadFileSystem + .Directory .EnumerateFiles(BundlesFolderPath, "*.yaml") .Concat(Build.ReadFileSystem.Directory.EnumerateFiles(BundlesFolderPath, "*.yml")) .ToList(); @@ -403,8 +408,7 @@ private void LoadConfiguration() => /// Both explicit :config: paths and auto-discovered candidates are validated /// against this same root. /// - private IDirectoryInfo ConfigTrustRoot => - Build.DocumentationCheckoutDirectory; + private IDirectoryInfo ConfigTrustRoot => Build.DocumentationCheckoutDirectory; private string? ResolveConfigPath() { @@ -423,14 +427,9 @@ private void LoadConfiguration() => } // Auto-discover: try .yml and .yaml in each candidate location. - string[] relativePaths = - [ - "changelog.yml", "changelog.yaml", - "../changelog.yml", "../changelog.yaml" - ]; - - return relativePaths - .Select(rel => Path.GetFullPath(Build.DocumentationSourceDirectory.ResolvePathFrom(rel))) + string[] relativePaths = ["changelog.yml", "changelog.yaml", "../changelog.yml", "../changelog.yaml"]; + + return relativePaths.Select(rel => Path.GetFullPath(Build.DocumentationSourceDirectory.ResolvePathFrom(rel))) .Select(abs => ValidateConfigCandidate(abs, emitDiagnostics: false)) .FirstOrDefault(p => p != null); } @@ -505,9 +504,7 @@ private void LoadAndCacheBundles() // Load bundles using the BundleLoader service // Emit errors (not warnings) for missing file references so the build fails fast // rather than silently omitting entries from the rendered output. - var loadedBundles = loader.LoadBundles( - BundlesFolderPath, - msg => this.EmitError(msg)); + var loadedBundles = loader.LoadBundles(BundlesFolderPath, msg => this.EmitError(msg)); ApplyLoadedBundles(loadedBundles); } @@ -523,7 +520,8 @@ private void LoadCdnBundles(string product) if (!Context.ReleaseNotesResolver.IsDeclared(product)) { this.EmitError( - $"The :cdn: product '{product}' is not declared in docset.yml. Add it under 'release_notes:', for example:\n release_notes:\n - product: {product}"); + $"The :cdn: product '{product}' is not declared in docset.yml. Add it under 'release_notes:', for example:\n release_notes:\n - product: {product}" + ); return; } @@ -538,9 +536,7 @@ private void ApplyLoadedBundles(IReadOnlyList loadedBundles) // Sort by version (descending - newest first) // Supports both semver (e.g., "9.3.0") and date-based (e.g., "2025-08-05") versions - var sortedBundles = filteredBundles - .OrderByDescending(b => VersionOrDate.Parse(b.Version)) - .ToList(); + var sortedBundles = filteredBundles.OrderByDescending(b => VersionOrDate.Parse(b.Version)).ToList(); // Always merge bundles with the same target version // (e.g., Cloud Serverless with multiple repos contributing to a single dated release) @@ -560,9 +556,7 @@ private IReadOnlyList FilterByVersion(IReadOnlyList if (VersionFilter is not { Length: > 0 } version) return bundles; - var matched = bundles - .Where(b => ChangelogVersionMatch.Matches(version, b.Version, b.FilePath)) - .ToList(); + var matched = bundles.Where(b => ChangelogVersionMatch.Matches(version, b.Version, b.FilePath)).ToList(); if (matched.Count == 0 && bundles.Count > 0) this.EmitWarning($"No changelog bundle matches :version: '{version}'."); @@ -598,12 +592,20 @@ private IEnumerable ComputeGeneratedAnchors() var entriesByType = GetFilteredEntryCounts(bundle); var shouldInclude = CreateTypeFilterPredicate(); - if (!dedicatedPage && shouldInclude(ChangelogEntryType.BreakingChange) && entriesByType.ContainsKey(ChangelogEntryType.BreakingChange)) + if ( + !dedicatedPage + && shouldInclude(ChangelogEntryType.BreakingChange) + && entriesByType.ContainsKey(ChangelogEntryType.BreakingChange) + ) yield return $"{repo}-{anchorSlug}-breaking-changes"; - if (!dedicatedPage && HighlightsEnabled && - ChangelogInlineRenderer.GetFilteredEntries(bundle, PublishBlocker, HideFeatures, TypeFilter) - .Any(e => e.Highlight == true)) + if ( + !dedicatedPage + && HighlightsEnabled + && ChangelogInlineRenderer.GetFilteredEntries(bundle, PublishBlocker, HideFeatures, TypeFilter).Any( + e => e.Highlight == true + ) + ) yield return $"{repo}-{anchorSlug}-highlights"; if (!dedicatedPage && shouldInclude(ChangelogEntryType.Security) && entriesByType.ContainsKey(ChangelogEntryType.Security)) @@ -612,12 +614,16 @@ private IEnumerable ComputeGeneratedAnchors() if (!dedicatedPage && shouldInclude(ChangelogEntryType.KnownIssue) && entriesByType.ContainsKey(ChangelogEntryType.KnownIssue)) yield return $"{repo}-{anchorSlug}-known-issues"; - if (!dedicatedPage && shouldInclude(ChangelogEntryType.Deprecation) && entriesByType.ContainsKey(ChangelogEntryType.Deprecation)) + if ( + !dedicatedPage && shouldInclude(ChangelogEntryType.Deprecation) && entriesByType.ContainsKey(ChangelogEntryType.Deprecation) + ) yield return $"{repo}-{anchorSlug}-deprecations"; - if (!dedicatedPage && shouldInclude(ChangelogEntryType.Feature) && - (entriesByType.ContainsKey(ChangelogEntryType.Feature) || - entriesByType.ContainsKey(ChangelogEntryType.Enhancement))) + if ( + !dedicatedPage + && shouldInclude(ChangelogEntryType.Feature) + && (entriesByType.ContainsKey(ChangelogEntryType.Feature) || entriesByType.ContainsKey(ChangelogEntryType.Enhancement)) + ) yield return $"{repo}-{anchorSlug}-features-enhancements"; if (!dedicatedPage && shouldInclude(ChangelogEntryType.BugFix) && entriesByType.ContainsKey(ChangelogEntryType.BugFix)) @@ -637,15 +643,17 @@ private IEnumerable ComputeGeneratedAnchors() /// /// Creates a predicate that returns true if the given entry type should be included based on the TypeFilter. /// - private Func CreateTypeFilterPredicate() => - TypeFilter switch - { - ChangelogTypeFilter.All => _ => true, - ChangelogTypeFilter.BreakingChange => type => type == ChangelogEntryType.BreakingChange, - ChangelogTypeFilter.Deprecation => type => type == ChangelogEntryType.Deprecation, - ChangelogTypeFilter.KnownIssue => type => type == ChangelogEntryType.KnownIssue, - _ => type => !SeparatedTypes.Contains(type) // Default: exclude separated types - }; + private Func CreateTypeFilterPredicate() => TypeFilter switch + { + ChangelogTypeFilter.All => _ => true, + ChangelogTypeFilter.BreakingChange => type => type == ChangelogEntryType.BreakingChange, + ChangelogTypeFilter.Deprecation => type => type == ChangelogEntryType.Deprecation, + ChangelogTypeFilter.KnownIssue => type => type == ChangelogEntryType.KnownIssue, + _ => + type => + !SeparatedTypes.Contains(type) // Default: exclude separated types + + }; /// /// Returns entry counts by type after applying publish blocker, hide-features, and type filters. @@ -657,8 +665,8 @@ private Dictionary GetFilteredEntryCounts(LoadedBundle .ToDictionary(g => g.Key, g => g.Count()); private bool BundleContributesToNavigation(LoadedBundle bundle) => - ChangelogInlineRenderer.BundleHasRenderableEntries(bundle, PublishBlocker, HideFeatures, TypeFilter) - || ChangelogInlineRenderer.ShouldRenderEmptyBundleMetadata(TypeFilter, bundle.Data?.Description); + ChangelogInlineRenderer.BundleHasRenderableEntries(bundle, PublishBlocker, HideFeatures, TypeFilter) || + ChangelogInlineRenderer.ShouldRenderEmptyBundleMetadata(TypeFilter, bundle.Data?.Description); private IEnumerable ComputeTableOfContent() { @@ -675,12 +683,7 @@ private IEnumerable ComputeTableOfContent() var displayVersion = VersionOrDate.FormatDisplayVersion(bundle.Version); string SectionSlug(string suffix) => $"{repo}-{anchorSlug}-{suffix}".Slugify(); - yield return new PageTocItem - { - Heading = displayVersion, - Slug = displayVersion.Slugify(), - Level = 2 - }; + yield return new PageTocItem { Heading = displayVersion, Slug = displayVersion.Slugify(), Level = 2 }; if (dedicatedPage) continue; @@ -689,50 +692,27 @@ private IEnumerable ComputeTableOfContent() var shouldInclude = CreateTypeFilterPredicate(); if (shouldInclude(ChangelogEntryType.BreakingChange) && entriesByType.ContainsKey(ChangelogEntryType.BreakingChange)) - yield return new PageTocItem - { - Heading = "Breaking changes", - Slug = SectionSlug("breaking-changes"), - Level = 3 - }; + yield return new PageTocItem { Heading = "Breaking changes", Slug = SectionSlug("breaking-changes"), Level = 3 }; - var hasHighlights = ChangelogInlineRenderer.GetFilteredEntries(bundle, PublishBlocker, HideFeatures, TypeFilter) - .Any(e => e.Highlight == true); + var hasHighlights = ChangelogInlineRenderer.GetFilteredEntries(bundle, PublishBlocker, HideFeatures, TypeFilter).Any( + e => e.Highlight == true + ); if (hasHighlights && HighlightsEnabled) - yield return new PageTocItem - { - Heading = "Highlights", - Slug = SectionSlug("highlights"), - Level = 3 - }; + yield return new PageTocItem { Heading = "Highlights", Slug = SectionSlug("highlights"), Level = 3 }; if (shouldInclude(ChangelogEntryType.Security) && entriesByType.ContainsKey(ChangelogEntryType.Security)) - yield return new PageTocItem - { - Heading = "Security", - Slug = SectionSlug("security"), - Level = 3 - }; + yield return new PageTocItem { Heading = "Security", Slug = SectionSlug("security"), Level = 3 }; if (shouldInclude(ChangelogEntryType.KnownIssue) && entriesByType.ContainsKey(ChangelogEntryType.KnownIssue)) - yield return new PageTocItem - { - Heading = "Known issues", - Slug = SectionSlug("known-issues"), - Level = 3 - }; + yield return new PageTocItem { Heading = "Known issues", Slug = SectionSlug("known-issues"), Level = 3 }; if (shouldInclude(ChangelogEntryType.Deprecation) && entriesByType.ContainsKey(ChangelogEntryType.Deprecation)) - yield return new PageTocItem - { - Heading = "Deprecations", - Slug = SectionSlug("deprecations"), - Level = 3 - }; + yield return new PageTocItem { Heading = "Deprecations", Slug = SectionSlug("deprecations"), Level = 3 }; - if (shouldInclude(ChangelogEntryType.Feature) && - (entriesByType.ContainsKey(ChangelogEntryType.Feature) || - entriesByType.ContainsKey(ChangelogEntryType.Enhancement))) + if ( + shouldInclude(ChangelogEntryType.Feature) && + (entriesByType.ContainsKey(ChangelogEntryType.Feature) || entriesByType.ContainsKey(ChangelogEntryType.Enhancement)) + ) yield return new PageTocItem { Heading = "Features and enhancements", @@ -741,36 +721,16 @@ private IEnumerable ComputeTableOfContent() }; if (shouldInclude(ChangelogEntryType.BugFix) && entriesByType.ContainsKey(ChangelogEntryType.BugFix)) - yield return new PageTocItem - { - Heading = "Fixes", - Slug = SectionSlug("fixes"), - Level = 3 - }; + yield return new PageTocItem { Heading = "Fixes", Slug = SectionSlug("fixes"), Level = 3 }; if (shouldInclude(ChangelogEntryType.Docs) && entriesByType.ContainsKey(ChangelogEntryType.Docs)) - yield return new PageTocItem - { - Heading = "Documentation", - Slug = SectionSlug("docs"), - Level = 3 - }; + yield return new PageTocItem { Heading = "Documentation", Slug = SectionSlug("docs"), Level = 3 }; if (shouldInclude(ChangelogEntryType.Regression) && entriesByType.ContainsKey(ChangelogEntryType.Regression)) - yield return new PageTocItem - { - Heading = "Regressions", - Slug = SectionSlug("regressions"), - Level = 3 - }; + yield return new PageTocItem { Heading = "Regressions", Slug = SectionSlug("regressions"), Level = 3 }; if (shouldInclude(ChangelogEntryType.Other) && entriesByType.ContainsKey(ChangelogEntryType.Other)) - yield return new PageTocItem - { - Heading = "Other changes", - Slug = SectionSlug("other"), - Level = 3 - }; + yield return new PageTocItem { Heading = "Other changes", Slug = SectionSlug("other"), Level = 3 }; } } } diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs index 42490f6f4c..3a1f346cda 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs @@ -60,9 +60,7 @@ public static class ChangelogInlineRenderer /// True when the directive filters to a single separated type page (deprecations, breaking changes, etc.). /// public static bool IsDedicatedSeparatedTypePage(ChangelogTypeFilter typeFilter) => - typeFilter is ChangelogTypeFilter.BreakingChange - or ChangelogTypeFilter.Deprecation - or ChangelogTypeFilter.KnownIssue; + typeFilter is ChangelogTypeFilter.BreakingChange or ChangelogTypeFilter.Deprecation or ChangelogTypeFilter.KnownIssue; /// /// True when is or default. @@ -77,7 +75,8 @@ public static IReadOnlyList GetFilteredEntries( LoadedBundle bundle, PublishBlocker? publishBlocker, HashSet hideFeatures, - ChangelogTypeFilter typeFilter) + ChangelogTypeFilter typeFilter + ) { var entries = FilterEntries(bundle.Entries, publishBlocker); entries = FilterEntriesByHideFeatures(entries, hideFeatures); @@ -91,8 +90,8 @@ public static bool BundleHasRenderableEntries( LoadedBundle bundle, PublishBlocker? publishBlocker, HashSet hideFeatures, - ChangelogTypeFilter typeFilter) => - GetFilteredEntries(bundle, publishBlocker, hideFeatures, typeFilter).Count > 0; + ChangelogTypeFilter typeFilter + ) => GetFilteredEntries(bundle, publishBlocker, hideFeatures, typeFilter).Count > 0; /// /// True when an empty bundle should still render a version block for bundle-level description only. @@ -115,9 +114,7 @@ private static string RenderSingleBundle(LoadedBundle bundle, ChangelogRenderOpt filteredEntries = FilterEntriesByType(filteredEntries, options.TypeFilter); // Group entries by type - var entriesByType = filteredEntries - .GroupBy(e => e.Type) - .ToDictionary(g => g.Key, g => g.ToList()); + var entriesByType = filteredEntries.GroupBy(e => e.Type).ToDictionary(g => g.Key, g => g.ToList()); var hideLinks = options.LinkVisibility switch { @@ -127,7 +124,11 @@ private static string RenderSingleBundle(LoadedBundle bundle, ChangelogRenderOpt _ => ShouldHideLinksForRepo(bundle.Repo, options.PrivateRepositories) }; - var hideEntryDescriptions = ShouldHideEntryDescriptionsForRepo(bundle.Repo, options.PrivateRepositories, options.DescriptionVisibility); + var hideEntryDescriptions = ShouldHideEntryDescriptionsForRepo( + bundle.Repo, + options.PrivateRepositories, + options.DescriptionVisibility + ); var model = new BundleRenderModel { @@ -150,14 +151,17 @@ private static string RenderSingleBundle(LoadedBundle bundle, ChangelogRenderOpt /// private static IReadOnlyList FilterEntriesByType( IReadOnlyList entries, - ChangelogTypeFilter typeFilter) => typeFilter switch - { - ChangelogTypeFilter.All => entries, - ChangelogTypeFilter.BreakingChange => entries.Where(e => e.Type == ChangelogEntryType.BreakingChange).ToList(), - ChangelogTypeFilter.Deprecation => entries.Where(e => e.Type == ChangelogEntryType.Deprecation).ToList(), - ChangelogTypeFilter.KnownIssue => entries.Where(e => e.Type == ChangelogEntryType.KnownIssue).ToList(), - _ => entries.Where(e => !ChangelogBlock.SeparatedTypes.Contains(e.Type)).ToList() // Default: exclude separated types - }; + ChangelogTypeFilter typeFilter + ) => typeFilter switch + { + ChangelogTypeFilter.All => entries, + ChangelogTypeFilter.BreakingChange => entries.Where(e => e.Type == ChangelogEntryType.BreakingChange).ToList(), + ChangelogTypeFilter.Deprecation => entries.Where(e => e.Type == ChangelogEntryType.Deprecation).ToList(), + ChangelogTypeFilter.KnownIssue => entries.Where(e => e.Type == ChangelogEntryType.KnownIssue).ToList(), + _ => + entries.Where(e => !ChangelogBlock.SeparatedTypes.Contains(e.Type)).ToList() // Default: exclude separated types + + }; /// /// Filters entries based on hide-features configuration from bundle metadata. @@ -165,14 +169,13 @@ private static IReadOnlyList FilterEntriesByType( /// private static IReadOnlyList FilterEntriesByHideFeatures( IReadOnlyList entries, - HashSet hideFeatures) + HashSet hideFeatures + ) { if (hideFeatures.Count == 0) return entries; - return entries - .Where(e => string.IsNullOrWhiteSpace(e.FeatureId) || !hideFeatures.Contains(e.FeatureId)) - .ToList(); + return entries.Where(e => string.IsNullOrWhiteSpace(e.FeatureId) || !hideFeatures.Contains(e.FeatureId)).ToList(); } /// @@ -194,15 +197,15 @@ public static bool ShouldHideLinksForRepo(string bundleRepo, HashSet pri public static bool ShouldHideEntryDescriptionsForRepo( string bundleRepo, HashSet privateRepositories, - ChangelogDescriptionVisibility visibility) => - visibility switch - { - ChangelogDescriptionVisibility.HideDescriptions => true, - ChangelogDescriptionVisibility.KeepHighlightDescriptions => true, - ChangelogDescriptionVisibility.KeepDescriptions => false, - ChangelogDescriptionVisibility.Auto => !HasAnyPrivateRepoConstituent(bundleRepo, privateRepositories), - _ => !HasAnyPrivateRepoConstituent(bundleRepo, privateRepositories) - }; + ChangelogDescriptionVisibility visibility + ) => visibility switch + { + ChangelogDescriptionVisibility.HideDescriptions => true, + ChangelogDescriptionVisibility.KeepHighlightDescriptions => true, + ChangelogDescriptionVisibility.KeepDescriptions => false, + ChangelogDescriptionVisibility.Auto => !HasAnyPrivateRepoConstituent(bundleRepo, privateRepositories), + _ => !HasAnyPrivateRepoConstituent(bundleRepo, privateRepositories) + }; /// /// True when merged (elasticsearch+kibana-style) has at least one @@ -221,9 +224,7 @@ public static bool HasAnyPrivateRepoConstituent(string bundleRepo, HashSet /// Filters entries based on publish blocker configuration. /// - private static IReadOnlyList FilterEntries( - IReadOnlyList entries, - PublishBlocker? publishBlocker) + private static IReadOnlyList FilterEntries(IReadOnlyList entries, PublishBlocker? publishBlocker) { if (publishBlocker is not { HasBlockingRules: true }) return entries; @@ -241,9 +242,8 @@ private static string GenerateMarkdown(BundleRenderModel model, ChangelogRenderO var subsections = options.Subsections; var hideLinks = model.HideLinks; var hideEntryDescriptions = model.HideEntryDescriptions; - var hideHighlightDescriptions = options.DescriptionVisibility - is not ChangelogDescriptionVisibility.KeepHighlightDescriptions - && hideEntryDescriptions; + var hideHighlightDescriptions = options.DescriptionVisibility is not ChangelogDescriptionVisibility.KeepHighlightDescriptions && + hideEntryDescriptions; var dropdownsEnabled = options.DropdownsEnabled; var typeFilter = options.TypeFilter; var publishBlocker = options.PublishBlocker; @@ -266,16 +266,20 @@ is not ChangelogDescriptionVisibility.KeepHighlightDescriptions var knownIssues = entriesByType.GetValueOrDefault(ChangelogEntryType.KnownIssue, []); // Get highlighted entries from all types - var highlights = entriesByType.Values - .SelectMany(e => e) - .Where(e => e.Highlight == true) - .ToList(); + var highlights = entriesByType.Values.SelectMany(e => e).Where(e => e.Highlight == true).ToList(); // Check if we have any content at all - var hasAnyContent = features.Count > 0 || enhancements.Count > 0 || security.Count > 0 || - bugFixes.Count > 0 || docs.Count > 0 || regressions.Count > 0 || other.Count > 0 || - breakingChanges.Count > 0 || deprecations.Count > 0 || knownIssues.Count > 0 || - highlights.Count > 0; + var hasAnyContent = features.Count > 0 + || enhancements.Count > 0 + || security.Count > 0 + || bugFixes.Count > 0 + || docs.Count > 0 + || regressions.Count > 0 + || other.Count > 0 + || breakingChanges.Count > 0 + || deprecations.Count > 0 + || knownIssues.Count > 0 + || highlights.Count > 0; if (!hasAnyContent) { @@ -294,7 +298,16 @@ is not ChangelogDescriptionVisibility.KeepHighlightDescriptions { AppendSectionHeader(sb, dedicatedPage, $"### Breaking changes [{repo}-{titleSlug}-breaking-changes]"); if (dropdownsEnabled) - RenderDetailedEntries(sb, breakingChanges, repo, owner, groupBySubtype: true, hideLinks, hideEntryDescriptions, publishBlocker); + RenderDetailedEntries( + sb, + breakingChanges, + repo, + owner, + groupBySubtype: true, + hideLinks, + hideEntryDescriptions, + publishBlocker + ); else RenderDetailedEntriesFlattened(sb, breakingChanges, repo, owner, groupBySubtype: true, hideLinks, hideEntryDescriptions); } @@ -304,7 +317,16 @@ is not ChangelogDescriptionVisibility.KeepHighlightDescriptions _ = sb.AppendLine(); _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Highlights [{repo}-{titleSlug}-highlights]"); if (dropdownsEnabled) - RenderDetailedEntries(sb, highlights, repo, owner, groupBySubtype: false, hideLinks, hideHighlightDescriptions, publishBlocker); + RenderDetailedEntries( + sb, + highlights, + repo, + owner, + groupBySubtype: false, + hideLinks, + hideHighlightDescriptions, + publishBlocker + ); else RenderDetailedEntriesFlattened(sb, highlights, repo, owner, groupBySubtype: false, hideLinks, hideHighlightDescriptions); } @@ -320,16 +342,34 @@ is not ChangelogDescriptionVisibility.KeepHighlightDescriptions { AppendSectionHeader(sb, dedicatedPage, $"### Known issues [{repo}-{titleSlug}-known-issues]"); RenderSeparatedTypeEntries( - sb, knownIssues, repo, owner, subsections, dropdownsEnabled, groupBySubtype: false, - hideLinks, hideEntryDescriptions, publishBlocker); + sb, + knownIssues, + repo, + owner, + subsections, + dropdownsEnabled, + groupBySubtype: false, + hideLinks, + hideEntryDescriptions, + publishBlocker + ); } if (deprecations.Count > 0) { AppendSectionHeader(sb, dedicatedPage, $"### Deprecations [{repo}-{titleSlug}-deprecations]"); RenderSeparatedTypeEntries( - sb, deprecations, repo, owner, subsections, dropdownsEnabled, groupBySubtype: false, - hideLinks, hideEntryDescriptions, publishBlocker); + sb, + deprecations, + repo, + owner, + subsections, + dropdownsEnabled, + groupBySubtype: false, + hideLinks, + hideEntryDescriptions, + publishBlocker + ); } if (features.Count > 0 || enhancements.Count > 0) @@ -379,7 +419,8 @@ private static void RenderEntriesByArea( bool subsections, bool hideLinks, bool hideEntryDescriptions, - PublishBlocker? publishBlocker) + PublishBlocker? publishBlocker + ) { if (subsections) { @@ -406,7 +447,14 @@ private static void RenderEntriesByArea( } } - private static void RenderSingleEntry(StringBuilder sb, ChangelogEntry entry, string repo, string owner, bool hideLinks, bool hideEntryDescriptions) + private static void RenderSingleEntry( + StringBuilder sb, + ChangelogEntry entry, + string repo, + string owner, + bool hideLinks, + bool hideEntryDescriptions + ) { _ = sb.Append("* "); _ = sb.Append(ChangelogTextUtilities.Beautify(entry.Title)); @@ -461,7 +509,8 @@ private static void RenderDetailedEntries( bool groupBySubtype, bool hideLinks, bool hideEntryDescriptions, - PublishBlocker? publishBlocker) + PublishBlocker? publishBlocker + ) { var grouped = groupBySubtype ? entries.GroupBy(e => e.Subtype?.ToStringFast(true) ?? string.Empty).OrderBy(g => g.Key).ToList() @@ -490,7 +539,8 @@ private static void RenderDetailedEntriesFlattened( string owner, bool groupBySubtype, bool hideLinks, - bool hideEntryDescriptions) + bool hideEntryDescriptions + ) { if (groupBySubtype) { @@ -526,7 +576,8 @@ private static void RenderDetailedEntriesFlattenedByArea( string owner, bool hideLinks, bool hideEntryDescriptions, - PublishBlocker? publishBlocker) + PublishBlocker? publishBlocker + ) { var groupedByArea = entries.GroupBy(e => publishBlocker.GetPreferredArea(e)).OrderBy(g => g.Key).ToList(); @@ -544,7 +595,14 @@ private static void RenderDetailedEntriesFlattenedByArea( } } - private static void RenderDetailedEntryFlattened(StringBuilder sb, ChangelogEntry entry, string repo, string owner, bool hideLinks, bool hideEntryDescriptions) + private static void RenderDetailedEntryFlattened( + StringBuilder sb, + ChangelogEntry entry, + string repo, + string owner, + bool hideLinks, + bool hideEntryDescriptions + ) { // Start with bullet point and title (no bold, matching regular entries) _ = sb.Append("* "); @@ -608,7 +666,14 @@ private static string GetLinksText(ChangelogEntry entry, string repo, string own return linksParts.Count > 0 ? string.Join(" ", linksParts) : string.Empty; } - private static void RenderDetailedEntry(StringBuilder sb, ChangelogEntry entry, string repo, string owner, bool hideLinks, bool hideEntryDescriptions) + private static void RenderDetailedEntry( + StringBuilder sb, + ChangelogEntry entry, + string repo, + string owner, + bool hideLinks, + bool hideEntryDescriptions + ) { _ = sb.AppendLine(); _ = sb.AppendLine(CultureInfo.InvariantCulture, $"::::{{dropdown}} {ChangelogTextUtilities.Beautify(entry.Title)}"); @@ -623,15 +688,21 @@ private static void RenderDetailedEntry(StringBuilder sb, ChangelogEntry entry, RenderDetailedEntryLinks(sb, entry, repo, owner, hideLinks); // Impact section - _ = sb.AppendLine(!string.IsNullOrWhiteSpace(entry.Impact) - ? "**Impact**
" + entry.Impact - : "% **Impact**
_Add a description of the impact_"); + _ = + sb.AppendLine( + !string.IsNullOrWhiteSpace(entry.Impact) + ? "**Impact**
" + entry.Impact + : "% **Impact**
_Add a description of the impact_" + ); _ = sb.AppendLine(); // Action section - _ = sb.AppendLine(!string.IsNullOrWhiteSpace(entry.Action) - ? "**Action**
" + entry.Action - : "% **Action**
_Add a description of what action to take_"); + _ = + sb.AppendLine( + !string.IsNullOrWhiteSpace(entry.Action) + ? "**Action**
" + entry.Action + : "% **Action**
_Add a description of what action to take_" + ); _ = sb.AppendLine("::::"); } @@ -740,7 +811,8 @@ private static void RenderSeparatedTypeEntries( bool groupBySubtype, bool hideLinks, bool hideEntryDescriptions, - PublishBlocker? publishBlocker) + PublishBlocker? publishBlocker + ) { if (dropdownsEnabled) { diff --git a/src/Elastic.Markdown/Myst/Directives/CliModifiers/CliModifiersBlock.cs b/src/Elastic.Markdown/Myst/Directives/CliModifiers/CliModifiersBlock.cs index 928ac14583..ed97a6ebcd 100644 --- a/src/Elastic.Markdown/Myst/Directives/CliModifiers/CliModifiersBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/CliModifiers/CliModifiersBlock.cs @@ -4,8 +4,7 @@ namespace Elastic.Markdown.Myst.Directives.CliModifiers; -public class CliModifiersBlock(DirectiveBlockParser parser, ParserContext context) - : DirectiveBlock(parser, context) +public class CliModifiersBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context) { public override string Directive => "cli-modifiers"; diff --git a/src/Elastic.Markdown/Myst/Directives/Contributors/ContributorsBlock.cs b/src/Elastic.Markdown/Myst/Directives/Contributors/ContributorsBlock.cs index 4719b935ef..627d7467d5 100644 --- a/src/Elastic.Markdown/Myst/Directives/Contributors/ContributorsBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Contributors/ContributorsBlock.cs @@ -29,14 +29,7 @@ public class ContributorEntry } /// Resolved contributor ready for rendering. -public record Contributor( - string? GitHub, - string Name, - string? Title, - string? Location, - string AvatarUrl, - string? ProfileUrl -); +public record Contributor(string? GitHub, string Name, string? Title, string? Location, string AvatarUrl, string? ProfileUrl); /// /// A backtick-fenced directive that renders a grid of contributor cards from YAML content. @@ -54,8 +47,7 @@ public record Contributor( /// location: Bucharest, Romania /// ``` /// -public class ContributorsBlock(BlockParser parser, ParserContext context) - : EnhancedCodeBlock(parser, context) +public class ContributorsBlock(BlockParser parser, ParserContext context) : EnhancedCodeBlock(parser, context) { /// Resolved contributor entries ready for rendering. public IReadOnlyList Contributors => _contributors; @@ -74,18 +66,9 @@ public void ResolveContributors(IReadOnlyList entries, ParserC } var avatarUrl = ResolveAvatarUrl(parserContext, entry.GitHub, entry.Image); - var profileUrl = !string.IsNullOrWhiteSpace(entry.GitHub) - ? $"https://github.com/{entry.GitHub}" - : null; - - _contributors.Add(new Contributor( - entry.GitHub, - entry.Name, - entry.Title, - entry.Location, - avatarUrl, - profileUrl - )); + var profileUrl = !string.IsNullOrWhiteSpace(entry.GitHub) ? $"https://github.com/{entry.GitHub}" : null; + + _contributors.Add(new Contributor(entry.GitHub, entry.Name, entry.Title, entry.Location, avatarUrl, profileUrl)); } if (_contributors.Count == 0) diff --git a/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvIncludeBlock.cs b/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvIncludeBlock.cs index 4d4d7fcc6d..d224cbdfe4 100644 --- a/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvIncludeBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvIncludeBlock.cs @@ -35,8 +35,6 @@ public override void FinalizeAndValidate(ParserContext context) if (!string.IsNullOrEmpty(separator)) Separator = separator; - - ExtractCsvPath(context); } diff --git a/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvIncludeViewModel.cs b/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvIncludeViewModel.cs index 0bfb1a57ea..4d883229db 100644 --- a/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvIncludeViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvIncludeViewModel.cs @@ -24,7 +24,9 @@ public IEnumerable GetCsvRows() { if (rowCount >= csvBlock.MaxRows) { - csvBlock.EmitWarning($"CSV file contains more than {csvBlock.MaxRows} rows. Only the first {csvBlock.MaxRows} rows will be displayed."); + csvBlock.EmitWarning( + $"CSV file contains more than {csvBlock.MaxRows} rows. Only the first {csvBlock.MaxRows} rows will be displayed." + ); return false; } @@ -32,7 +34,9 @@ public IEnumerable GetCsvRows() { if (!columnCountExceeded) { - csvBlock.EmitWarning($"CSV file contains more than {csvBlock.MaxColumns} columns. Only the first {csvBlock.MaxColumns} columns will be displayed."); + csvBlock.EmitWarning( + $"CSV file contains more than {csvBlock.MaxColumns} columns. Only the first {csvBlock.MaxColumns} columns will be displayed." + ); columnCountExceeded = true; } } @@ -60,9 +64,5 @@ public HtmlString RenderCell(string? value) } public static CsvIncludeViewModel Create(CsvIncludeBlock csvBlock, Func renderMarkdown) => - new() - { - DirectiveBlock = csvBlock, - RenderMarkdown = renderMarkdown - }; + new() { DirectiveBlock = csvBlock, RenderMarkdown = renderMarkdown }; } diff --git a/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs b/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs index 771a533a39..ce68a932d5 100644 --- a/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs +++ b/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs @@ -10,7 +10,8 @@ namespace Elastic.Markdown.Myst.Directives.CsvInclude; public static class CsvReader { - public static IEnumerable ReadCsvFile(string filePath, string separator, IFileSystem fileSystem) => ReadWithSep(filePath, separator, fileSystem); + public static IEnumerable ReadCsvFile(string filePath, string separator, IFileSystem fileSystem) => + ReadWithSep(filePath, separator, fileSystem); private static IEnumerable ReadWithSep(string filePath, string separator, IFileSystem fileSystem) { diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveBlock.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveBlock.cs index 92b120caf4..9464d7510d 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveBlock.cs @@ -46,10 +46,9 @@ public interface IBlockExtension : IBlock /// /// The parser used to create this block. /// -public abstract class DirectiveBlock( - DirectiveBlockParser parser, - ParserContext context) - : ContainerBlock(parser), IFencedBlock, IBlockExtension +public abstract class DirectiveBlock(DirectiveBlockParser parser, ParserContext context) : ContainerBlock( + parser +), IFencedBlock, IBlockExtension { private Dictionary? _properties; protected IReadOnlyDictionary? Properties => _properties; @@ -142,7 +141,6 @@ protected bool PropBool(params string[] keys) return bool.TryParse(value, out var result) ? result : null; } - protected string? Prop(params string[] keys) { if (Properties is null) @@ -162,8 +160,7 @@ protected bool PropBool(params string[] keys) /// with the snippet line to ensure uniqueness across multiple includes and multiple blocks. /// /// A unique integer index suitable for generating HTML IDs. - protected int GetUniqueLineIndex() => - IncludeLine.HasValue ? (IncludeLine.Value * 1000) + Line : Line; + protected int GetUniqueLineIndex() => IncludeLine.HasValue ? (IncludeLine.Value * 1000) + Line : Line; /// /// Additional anchors that this directive will generate during rendering. diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs index d9e56299ad..a2d70d63c2 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs @@ -78,8 +78,7 @@ public DirectiveBlockParser() { "csv-table", 33 } }.ToFrozenDictionary(); - private static readonly FrozenDictionary.AlternateLookup> UnsupportedLookup = - UnsupportedBlocks.GetAlternateLookup>(); + private static readonly FrozenDictionary.AlternateLookup> UnsupportedLookup = UnsupportedBlocks.GetAlternateLookup>(); protected override DirectiveBlock CreateFencedBlock(BlockProcessor processor) { @@ -274,6 +273,5 @@ public override BlockState TryContinue(BlockProcessor processor, Block block) directiveBlock.AddProperty(name, data); return BlockState.Continue; - } } diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index 904c963f35..fd62854190 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -193,20 +193,26 @@ private static void WriteImageCarousel(HtmlRenderer renderer, ImageCarouselBlock var slice = ImageCarouselView.Create(new ImageCarouselViewModel { DirectiveBlock = block, - Images = block.Images.Select(img => new ImageViewModel - { - DirectiveBlock = img, - Label = img.Label, - Align = img.Align ?? string.Empty, - Alt = img.Alt ?? string.Empty, - Title = img.Title, - Height = img.Height, - Width = img.Width, - Scale = img.Scale ?? string.Empty, - Screenshot = img.Screenshot, - Target = img.Target, - ImageUrl = img.ImageUrl - }).ToList(), + Images = + block.Images + .Select( + img => + new ImageViewModel + { + DirectiveBlock = img, + Label = img.Label, + Align = img.Align ?? string.Empty, + Alt = img.Alt ?? string.Empty, + Title = img.Title, + Height = img.Height, + Width = img.Width, + Scale = img.Scale ?? string.Empty, + Screenshot = img.Screenshot, + Target = img.Target, + ImageUrl = img.ImageUrl + } + ) + .ToList(), MaxHeight = block.MaxHeight }); RenderRazorSlice(slice, renderer); @@ -298,15 +304,21 @@ private static void WriteGetStarted(HtmlRenderer renderer, GetStartedBlock block DescriptionHtml = RenderInlineMarkdown(step.Description), Link = step.Link, LinkLabel = step.LinkLabel, - Options = [.. step.Options.Select(option => new GetStartedOptionViewModel - { - Label = option.Label, - DescriptionHtml = RenderInlineMarkdown(option.Description), - Code = option.Code, - Language = option.Language, - Url = option.Url, - UrlLabel = option.UrlLabel - })] + Options = + [ + .. step.Options.Select( + option => + new GetStartedOptionViewModel + { + Label = option.Label, + DescriptionHtml = RenderInlineMarkdown(option.Description), + Code = option.Code, + Language = option.Language, + Url = option.Url, + UrlLabel = option.UrlLabel + } + ) + ] }); } @@ -339,12 +351,7 @@ private static void WriteGetStarted(HtmlRenderer renderer, GetStartedBlock block private static void WritePageCard(HtmlRenderer renderer, PageCardBlock block) { - var slice = PageCardView.Create(new PageCardViewModel - { - DirectiveBlock = block, - Title = block.Title, - Url = block.ResolvedUrl - }); + var slice = PageCardView.Create(new PageCardViewModel { DirectiveBlock = block, Title = block.Title, Url = block.ResolvedUrl }); RenderRazorSlice(slice, renderer); } @@ -368,11 +375,7 @@ private static void WriteStepBlock(HtmlRenderer renderer, StepBlock block) private static void WriteButtonGroup(HtmlRenderer renderer, ButtonGroupBlock block) { - var slice = ButtonGroupView.Create(new ButtonGroupViewModel - { - DirectiveBlock = block, - Align = block.Align - }); + var slice = ButtonGroupView.Create(new ButtonGroupViewModel { DirectiveBlock = block, Align = block.Align }); RenderRazorSlice(slice, renderer); } @@ -390,21 +393,13 @@ private static void WriteButton(HtmlRenderer renderer, ButtonBlock block) private static void WriteListSubPages(HtmlRenderer renderer, ListSubPagesBlock block) { - var slice = ListSubPagesView.Create(new ListSubPagesViewModel - { - DirectiveBlock = block, - SubPages = block.SubPages - }); + var slice = ListSubPagesView.Create(new ListSubPagesViewModel { DirectiveBlock = block, SubPages = block.SubPages }); RenderRazorSlice(slice, renderer); } private static void WriteListing(HtmlRenderer renderer, ListingBlock block) { - var slice = ListingView.Create(new ListingViewModel - { - DirectiveBlock = block, - Entries = block.Entries - }); + var slice = ListingView.Create(new ListingViewModel { DirectiveBlock = block, Entries = block.Entries }); RenderRazorSlice(slice, renderer); } @@ -422,9 +417,15 @@ private static void WriteTableDirective(HtmlRenderer renderer, TableDirectiveBlo private static void WriteCliModifiers(HtmlRenderer renderer, CliModifiersBlock block) { - if (!block.Destructive && !block.RequiresConfirmation && !block.RequiresAuth - && !block.Idempotent && string.IsNullOrWhiteSpace(block.Scope) - && !block.Streaming && !block.LongRunning) + if ( + !block.Destructive + && !block.RequiresConfirmation + && !block.RequiresAuth + && !block.Idempotent + && string.IsNullOrWhiteSpace(block.Scope) + && !block.Streaming + && !block.LongRunning + ) return; var slice = CliModifiersView.Create(new CliModifiersViewModel @@ -486,8 +487,7 @@ private static void WriteStorybook(HtmlRenderer renderer, StorybookBlock block) private static void WriteFigure(HtmlRenderer renderer, ImageBlock block) { - var imageUrl = block.ImageUrl != null && - (block.ImageUrl.StartsWith("/_static") || block.ImageUrl.StartsWith("_static")) + var imageUrl = block.ImageUrl != null && (block.ImageUrl.StartsWith("/_static") || block.ImageUrl.StartsWith("_static")) ? $"{block.Build.UrlPathPrefix}/{block.ImageUrl.TrimStart('/')}" : block.ImageUrl; var slice = FigureView.Create(new ImageViewModel @@ -507,8 +507,7 @@ private static void WriteFigure(HtmlRenderer renderer, ImageBlock block) RenderRazorSlice(slice, renderer); } - private static void WriteChildren(HtmlRenderer renderer, DirectiveBlock directiveBlock) => - renderer.WriteChildren(directiveBlock); + private static void WriteChildren(HtmlRenderer renderer, DirectiveBlock directiveBlock) => renderer.WriteChildren(directiveBlock); private static void WriteVersion(HtmlRenderer renderer, VersionBlock block) { @@ -586,7 +585,8 @@ private static void WriteAppliesItem(HtmlRenderer renderer, AppliesItemBlock blo { // Parse the applies_to definition to get the ApplicableTo object // Use the pre-parsed AppliesTo from the block (implementing IBlockAppliesTo) - var appliesTo = block.AppliesTo ?? (block.AppliesToDefinition is not null ? ParseApplicableTo(block.AppliesToDefinition, block) : null); + var appliesTo = block.AppliesTo ?? + (block.AppliesToDefinition is not null ? ParseApplicableTo(block.AppliesToDefinition, block) : null); var slice = AppliesItemView.Create(new AppliesItemViewModel { DirectiveBlock = block, @@ -652,8 +652,15 @@ private static void WriteIncludeBlock(HtmlRenderer renderer, IncludeBlock block) var snippet = block.Build.ReadFileSystem.FileInfo.New(block.IncludePath); var parentPath = block.Context.MarkdownParentPath ?? block.Context.MarkdownSourcePath; - var document = MarkdownParser.ParseSnippetAsync(block.Build, block.Context, snippet, parentPath, block.Context.YamlFrontMatter, default, block.Line) - .GetAwaiter().GetResult(); + var document = MarkdownParser.ParseSnippetAsync( + block.Build, + block.Context, + snippet, + parentPath, + block.Context.YamlFrontMatter, + default, + block.Line + ).GetAwaiter().GetResult(); var html = document.ToHtml(MarkdownParser.Pipeline); _ = renderer.Write(html); @@ -670,10 +677,11 @@ private static void WriteSettingsBlock(HtmlRenderer renderer, SettingsBlock bloc try { var yaml = file.FileSystem.File.ReadAllText(file.FullName); - settings = SettingsBlock.PrepareSettingsForRendering( - YamlSerialization.Deserialize(yaml, block.Context.Build.ProductsConfiguration), - block.Context - ); + settings = + SettingsBlock.PrepareSettingsForRendering( + YamlSerialization.Deserialize(yaml, block.Context.Build.ProductsConfiguration), + block.Context + ); } catch (YamlException e) { @@ -703,7 +711,8 @@ private static void WriteSettingsBlock(HtmlRenderer renderer, SettingsBlock bloc settingsSourceFile, block.Context.YamlFrontMatter, block.IncludeFrom, - MarkdownParser.Pipeline); + MarkdownParser.Pipeline + ); var html = document.ToHtml(MarkdownParser.Pipeline); // Trim to ensure consistent whitespace @@ -722,8 +731,11 @@ private static void RenderRazorSlice(RazorSlice slice, HtmlRenderer render } [SuppressMessage("Reliability", "CA2012:Use ValueTasks correctly")] - private static void RenderRazorSliceRawContent(RazorSlice slice, HtmlRenderer renderer, DirectiveBlock obj) - where T : DirectiveViewModel + private static void RenderRazorSliceRawContent( + RazorSlice slice, + HtmlRenderer renderer, + DirectiveBlock obj + ) where T : DirectiveViewModel { var html = slice.RenderAsync().GetAwaiter().GetResult(); var blocks = html.Split("[CONTENT]", 2, StringSplitOptions.RemoveEmptyEntries); @@ -750,7 +762,6 @@ void RenderLeaf(LeafBlock p) _ = renderer.Write(new string(r.DelimiterChar, r.DelimiterCount)); renderer.WriteChildren(r); } - else _ = renderer.Write($"(LeafBlock: {oo.GetType().Name}"); } @@ -805,7 +816,8 @@ private static HtmlString RenderCsvCellMarkdown(CsvIncludeBlock block, string va value, block.IncludeFrom, block.Context.YamlFrontMatter, - MarkdownParser.Pipeline); + MarkdownParser.Pipeline + ); if (document.Count == 1 && document.FirstOrDefault() is ParagraphBlock paragraph && paragraph.Inline != null) return RenderInlineMarkdown(paragraph); @@ -848,7 +860,8 @@ private static void WriteChangelogBlock(HtmlRenderer renderer, ChangelogBlock bl markdown, block.CurrentFile, block.Context.YamlFrontMatter, - MarkdownParser.Pipeline); + MarkdownParser.Pipeline + ); var html = document.ToHtml(MarkdownParser.Pipeline); _ = renderer.Write(html); diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveLinkValidator.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveLinkValidator.cs index 09ccc14d6f..1f12397654 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveLinkValidator.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveLinkValidator.cs @@ -52,7 +52,9 @@ internal static class DirectiveLinkValidator if (!trimmed.StartsWith('/') && !allowRelative) { - block.EmitError($"Directive link `{url}` must be an absolute path starting with `/`, a cross-link scheme (for example `kibana://`), or an external URL."); + block.EmitError( + $"Directive link `{url}` must be an absolute path starting with `/`, a cross-link scheme (for example `kibana://`), or an external URL." + ); return url; } @@ -100,13 +102,13 @@ private static void RememberCrossLink(DirectiveBlock block, string resolved) /// True when came from a cross-link scheme on this block. public static bool IsResolvedCrossLink(DirectiveBlock block, string? url) => url is not null - && block.GetData(ResolvedCrossLinksKey) is HashSet resolvedLinks - && resolvedLinks.Contains(url, StringComparer.OrdinalIgnoreCase); + && block.GetData(ResolvedCrossLinksKey) is HashSet resolvedLinks + && resolvedLinks.Contains(url, StringComparer.OrdinalIgnoreCase); private static bool IsExternal(string url) => url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) - || url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) - || url.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase); + || url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + || url.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase); private static string StripMarkdownExtension(string path) { @@ -118,9 +120,7 @@ private static string StripMarkdownExtension(string path) return stripped.Length == 0 ? "/" : stripped; } - return path.EndsWith(".md", StringComparison.OrdinalIgnoreCase) - ? path[..^".md".Length] - : path; + return path.EndsWith(".md", StringComparison.OrdinalIgnoreCase) ? path[..^".md".Length] : path; } private static string ResolveCrossLink(string original, Uri uri, DirectiveBlock block, ParserContext context) @@ -131,7 +131,9 @@ private static string ResolveCrossLink(string original, Uri uri, DirectiveBlock // Custom passthrough protocols (cursor:, vscode:) are left alone. if (IsPassthroughCustomProtocolScheme(uri.Scheme)) return original; - block.EmitError($"Directive link `{original}` uses cross-link scheme `{uri.Scheme}://` which is not declared under `cross_links` in docset.yml."); + block.EmitError( + $"Directive link `{original}` uses cross-link scheme `{uri.Scheme}://` which is not declared under `cross_links` in docset.yml." + ); return original; } @@ -180,20 +182,14 @@ private static void ValidateInternal(string url, DirectiveBlock block, ParserCon // docs-builder URLs usually omit the extension, so /explore-analyze/discover may mean // discover.md or discover/index.md. Probe as given first. private static string[] ProbeCandidates(string path) => - path.EndsWith(".md", StringComparison.OrdinalIgnoreCase) - ? [path] - : [path, path + ".md", path.TrimEnd('/') + "/index.md"]; + path.EndsWith(".md", StringComparison.OrdinalIgnoreCase) ? [path] : [path, path + ".md", path.TrimEnd('/') + "/index.md"]; private static bool TryEmitRedirectWarning(string url, string relativeToBase, DirectiveBlock block, ParserContext context) { - if (context.Configuration.Redirects is null - || !context.Configuration.Redirects.TryGetValue(relativeToBase, out var redirect)) + if (context.Configuration.Redirects is null || !context.Configuration.Redirects.TryGetValue(relativeToBase, out var redirect)) return false; - var to = redirect.To - ?? (redirect.Many is not null - ? string.Join(", ", redirect.Many.Select(m => m.To)) - : "unknown"); + var to = redirect.To ?? (redirect.Many is not null ? string.Join(", ", redirect.Many.Select(m => m.To)) : "unknown"); block.EmitWarning($"Directive link `{url}` has a redirect; update to: {to}"); return true; } @@ -205,6 +201,5 @@ private static (string Path, string? Anchor) SplitAnchor(string url) } private static bool IsPassthroughCustomProtocolScheme(string scheme) => - scheme.Equals("cursor", StringComparison.OrdinalIgnoreCase) - || scheme.StartsWith("vscode", StringComparison.OrdinalIgnoreCase); + scheme.Equals("cursor", StringComparison.OrdinalIgnoreCase) || scheme.StartsWith("vscode", StringComparison.OrdinalIgnoreCase); } diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveMarkdownExtension.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveMarkdownExtension.cs index dd3a859526..ba0ed75794 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveMarkdownExtension.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveMarkdownExtension.cs @@ -43,10 +43,11 @@ public void Setup(MarkdownPipelineBuilder pipeline) if (inlineParser != null && !inlineParser.HasEmphasisChar(':')) { inlineParser.EmphasisDescriptors.Add(new EmphasisDescriptor(':', 2, 2, true)); - inlineParser.TryCreateEmphasisInlineList.Add((emphasisChar, delimiterCount) => - delimiterCount != 2 || emphasisChar != ':' - ? null - : (Markdig.Syntax.Inlines.EmphasisInline)new Role { DelimiterChar = ':', DelimiterCount = 2 } + inlineParser.TryCreateEmphasisInlineList.Add( + (emphasisChar, delimiterCount) => + delimiterCount != 2 || emphasisChar != ':' + ? null + : (Markdig.Syntax.Inlines.EmphasisInline)new Role { DelimiterChar = ':', DelimiterCount = 2 } ); } } diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveParagraphParser.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveParagraphParser.cs index 786ce87db6..6757a1ae67 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveParagraphParser.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveParagraphParser.cs @@ -35,8 +35,6 @@ public override BlockState TryContinue(BlockProcessor processor, Block block) return base.TryContinue(processor, block); var line = lines[0]; - return line.Slice.AsSpan().StartsWith(':') - ? BlockState.BreakDiscard - : base.TryContinue(processor, block); + return line.Slice.AsSpan().StartsWith(':') ? BlockState.BreakDiscard : base.TryContinue(processor, block); } } diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupBlock.cs index ed6a11d43a..fa58478f47 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupBlock.cs @@ -23,8 +23,7 @@ namespace Elastic.Markdown.Myst.Directives.Hub; /// :::: /// /// -public class CardGroupBlock(DirectiveBlockParser parser, ParserContext context) - : DirectiveBlock(parser, context) +public class CardGroupBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context) { public override string Directive => "card-group"; @@ -41,6 +40,5 @@ public override void FinalizeAndValidate(ParserContext context) Variant = Prop("variant"); } - public override IEnumerable GeneratedAnchors => - string.IsNullOrWhiteSpace(Anchor) ? [] : [Anchor]; + public override IEnumerable GeneratedAnchors => string.IsNullOrWhiteSpace(Anchor) ? [] : [Anchor]; } diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/ExploreBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/ExploreBlock.cs index 781e343107..427df7e5c1 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/ExploreBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/ExploreBlock.cs @@ -25,8 +25,7 @@ namespace Elastic.Markdown.Myst.Directives.Hub; /// ::::: /// /// -public class ExploreBlock(DirectiveBlockParser parser, ParserContext context) - : DirectiveBlock(parser, context) +public class ExploreBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context) { public override string Directive => "explore"; @@ -44,6 +43,5 @@ public override void FinalizeAndValidate(ParserContext context) this.EmitError("{explore} requires a `:title:` option."); } - public override IEnumerable GeneratedAnchors => - string.IsNullOrWhiteSpace(Anchor) ? [] : [Anchor]; + public override IEnumerable GeneratedAnchors => string.IsNullOrWhiteSpace(Anchor) ? [] : [Anchor]; } diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedBlock.cs index d2915faa2a..f025438591 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedBlock.cs @@ -22,8 +22,7 @@ namespace Elastic.Markdown.Myst.Directives.Hub; /// ::: /// /// -public class GetStartedBlock(DirectiveBlockParser parser, ParserContext context) - : DirectiveBlock(parser, context) +public class GetStartedBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context) { public override string Directive => "get-started"; @@ -51,7 +50,6 @@ public override void FinalizeAndValidate(ParserContext context) if (string.IsNullOrWhiteSpace(Data.Title)) this.EmitError("{get-started} requires a `title` field in its YAML body."); - foreach (var step in Data.Steps) { if (!string.IsNullOrWhiteSpace(step.Link)) diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs index 913cafadb4..626e91c1e7 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs @@ -24,8 +24,7 @@ namespace Elastic.Markdown.Myst.Directives.Hub; /// ::: /// /// -public partial class HeroBlock(DirectiveBlockParser parser, ParserContext context) - : DirectiveBlock(parser, context) +public partial class HeroBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context) { public override string Directive => "hero"; diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HubDirectiveViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/HubDirectiveViewModel.cs index b7d71f80e8..9df759fa20 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/HubDirectiveViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HubDirectiveViewModel.cs @@ -42,8 +42,7 @@ public HtmlString LinkAttributes(string? url) return new HtmlString(attributes.ToString()); } - private static bool IsExternal(string? url) => - url is not null && url.StartsWith("http", StringComparison.OrdinalIgnoreCase); + private static bool IsExternal(string? url) => url is not null && url.StartsWith("http", StringComparison.OrdinalIgnoreCase); private static bool IsAnchor(string? url) => url is ['#', ..]; diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardBlock.cs index d98e21d3c2..38602c2200 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardBlock.cs @@ -26,8 +26,7 @@ namespace Elastic.Markdown.Myst.Directives.Hub; /// ::: /// /// -public class LinkCardBlock(DirectiveBlockParser parser, ParserContext context) - : DirectiveBlock(parser, context), IBlockTitle +public class LinkCardBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context), IBlockTitle { public override string Directive => "link-card"; diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardViewModel.cs index cb9b7e8d26..056606de36 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardViewModel.cs @@ -11,5 +11,4 @@ public class LinkCardViewModel : HubDirectiveViewModel /// Rendered as a titled link column inside an {explore} accordion. public bool IsColumn { get; init; } - } diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/ProductIcons.cs b/src/Elastic.Markdown/Myst/Directives/Hub/ProductIcons.cs index 98b96b2a94..d65ee6b542 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/ProductIcons.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/ProductIcons.cs @@ -15,14 +15,16 @@ public static class ProductIcons { private static readonly FrozenDictionary Icons = new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["elasticsearch"] = """ + ["elasticsearch"] = + """ """, - ["kibana"] = """ + ["kibana"] = + """ """, - ["observability"] = """ + ["observability"] = + """ """, - ["security"] = """ + ["security"] = + """ """, - ["logstash"] = """ + ["logstash"] = + """ - public static string Initials(string? key) => - string.IsNullOrWhiteSpace(key) ? "?" : char.ToUpperInvariant(key[0]).ToString(); + public static string Initials(string? key) => string.IsNullOrWhiteSpace(key) ? "?" : char.ToUpperInvariant(key[0]).ToString(); } diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs index 1c54c33e2c..a5de991f6a 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs @@ -27,8 +27,7 @@ namespace Elastic.Markdown.Myst.Directives.Hub; /// etc. directly. Useful for one-offs that don't belong in the central /// feed. ///
-public class WhatsNewBlock(DirectiveBlockParser parser, ParserContext context) - : DirectiveBlock(parser, context) +public class WhatsNewBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context) { private const string WhatsNewFileName = "hub-whats-new.yml"; @@ -47,7 +46,9 @@ public override void FinalizeAndValidate(ParserContext context) var resolved = LoadFromCentralConfig(product); if (resolved is null) { - this.EmitError($"{{whats-new}} :product: '{product}' was not found in {WhatsNewFileName} at the root of this documentation set."); + this.EmitError( + $"{{whats-new}} :product: '{product}' was not found in {WhatsNewFileName} at the root of this documentation set." + ); return; } Data = resolved; @@ -97,26 +98,28 @@ private void ValidateLinks(ParserContext context) if (!Build.ReadFileSystem.File.Exists(path)) return null; - var config = CentralConfigCache.GetOrAdd(path, p => - { - try - { - var yaml = Build.ReadFileSystem.File.ReadAllText(p); - return YamlSerialization.Deserialize(yaml, Build.ProductsConfiguration); - } - catch + var config = CentralConfigCache.GetOrAdd( + path, + p => { - return null; + try + { + var yaml = Build.ReadFileSystem.File.ReadAllText(p); + return YamlSerialization.Deserialize(yaml, Build.ProductsConfiguration); + } + catch + { + return null; + } } - }); + ); if (config?.Products is null) return null; return config.Products.TryGetValue(productKey, out var data) ? data : null; } - public override IEnumerable GeneratedAnchors => - string.IsNullOrWhiteSpace(Data.Id) ? [] : [Data.Id]; + public override IEnumerable GeneratedAnchors => string.IsNullOrWhiteSpace(Data.Id) ? [] : [Data.Id]; } [YamlSerializable] diff --git a/src/Elastic.Markdown/Myst/Directives/Image/ImageBlock.cs b/src/Elastic.Markdown/Myst/Directives/Image/ImageBlock.cs index db4bd0cb22..550ac7aa07 100644 --- a/src/Elastic.Markdown/Myst/Directives/Image/ImageBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Image/ImageBlock.cs @@ -11,8 +11,7 @@ namespace Elastic.Markdown.Myst.Directives.Image; public class FigureBlock(DirectiveBlockParser parser, ParserContext context) : ImageBlock(parser, context); -public class ImageBlock(DirectiveBlockParser parser, ParserContext context) - : DirectiveBlock(parser, context) +public class ImageBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context) { public override string Directive => "image"; diff --git a/src/Elastic.Markdown/Myst/Directives/Image/ImageViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Image/ImageViewModel.cs index 21cf5ee8cf..d4798dba60 100644 --- a/src/Elastic.Markdown/Myst/Directives/Image/ImageViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Image/ImageViewModel.cs @@ -24,6 +24,7 @@ public class ImageViewModel : DirectiveViewModel public string UniqueImageId => field ??= string.IsNullOrEmpty(ImageUrl) ? Guid.NewGuid().ToString("N")[..8] // fallback to a random ID if ImageUrl is null or empty + : ShortId.Create(ImageUrl); public required string? Screenshot { get; init; } diff --git a/src/Elastic.Markdown/Myst/Directives/Include/IncludeBlock.cs b/src/Elastic.Markdown/Myst/Directives/Include/IncludeBlock.cs index 9948c4e354..d78c20198e 100644 --- a/src/Elastic.Markdown/Myst/Directives/Include/IncludeBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Include/IncludeBlock.cs @@ -12,8 +12,7 @@ namespace Elastic.Markdown.Myst.Directives.Include; public class LiteralIncludeBlock : IncludeBlock { - public LiteralIncludeBlock(DirectiveBlockParser parser, ParserContext context) : base(parser, context) => - Literal = true; + public LiteralIncludeBlock(DirectiveBlockParser parser, ParserContext context) : base(parser, context) => Literal = true; public override string Directive => "literalinclude"; } @@ -93,7 +92,10 @@ private void ExtractInclusionPath(ParserContext context) if (Literal) return; - if (file.Directory != null && !file.Directory.FullName.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Contains("_snippets")) + if ( + file.Directory != null && + !file.Directory.FullName.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Contains("_snippets") + ) { this.EmitError($"{{include}} only supports including snippets from `_snippet` folders. `{IncludePath}` is not a snippet"); Found = false; diff --git a/src/Elastic.Markdown/Myst/Directives/Listing/ListingBlock.cs b/src/Elastic.Markdown/Myst/Directives/Listing/ListingBlock.cs index af48369fda..70506c51ed 100644 --- a/src/Elastic.Markdown/Myst/Directives/Listing/ListingBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Listing/ListingBlock.cs @@ -61,7 +61,8 @@ private static void CollectEntries( List entries, string? groupKey, string? groupTitle, - bool isRoot) + bool isRoot + ) { foreach (var item in node.NavigationItems) { @@ -72,18 +73,10 @@ private static void CollectEntries( var gTitle = groupNode.NavigationTitle; CollectEntries(groupNode, entries, groupNode.Url, gTitle, isRoot: false); break; - // Content page leaf — add as a card case ILeafNavigationItem leaf: - entries.Add(new ListingEntry( - leaf.NavigationTitle, - leaf.Url, - leaf.Model.Description, - groupKey, - groupTitle - )); + entries.Add(new ListingEntry(leaf.NavigationTitle, leaf.Url, leaf.Model.Description, groupKey, groupTitle)); break; - // Folder inside a group — flatten case INodeNavigationItem subNode: CollectEntries(subNode, entries, groupKey, groupTitle, isRoot: false); diff --git a/src/Elastic.Markdown/Myst/Directives/Math/MathBlock.cs b/src/Elastic.Markdown/Myst/Directives/Math/MathBlock.cs index 588116decb..3070038b22 100644 --- a/src/Elastic.Markdown/Myst/Directives/Math/MathBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Math/MathBlock.cs @@ -84,8 +84,14 @@ private static bool DetermineDisplayMath(string content) // Check for block-level math expressions (heuristics) // If content contains line breaks or complex expressions, likely display math - if (content.Contains('\n') || content.Contains("\\frac") || content.Contains("\\sum") || - content.Contains("\\int") || content.Contains("\\lim") || content.Contains("\\begin")) + if ( + content.Contains('\n') + || content.Contains("\\frac") + || content.Contains("\\sum") + || content.Contains("\\int") + || content.Contains("\\lim") + || content.Contains("\\begin") + ) return true; return false; diff --git a/src/Elastic.Markdown/Myst/Directives/PageCard/PageCardBlock.cs b/src/Elastic.Markdown/Myst/Directives/PageCard/PageCardBlock.cs index b3432dcd2a..3b027b7c93 100644 --- a/src/Elastic.Markdown/Myst/Directives/PageCard/PageCardBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/PageCard/PageCardBlock.cs @@ -7,8 +7,7 @@ namespace Elastic.Markdown.Myst.Directives.PageCard; -public partial class PageCardBlock(DirectiveBlockParser parser, ParserContext context) - : DirectiveBlock(parser, context) +public partial class PageCardBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context) { public override string Directive => "page-card"; @@ -33,8 +32,7 @@ public override void FinalizeAndValidate(ParserContext context) Title = match.Groups[1].Value; var url = match.Groups[2].Value; - if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || - url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { this.EmitError($"page-card url must be a local .md path or crosslink, not an absolute URL: {url}"); return; diff --git a/src/Elastic.Markdown/Myst/Directives/Settings/SettingsBlock.cs b/src/Elastic.Markdown/Myst/Directives/Settings/SettingsBlock.cs index 58c0a3759f..05819f78bb 100644 --- a/src/Elastic.Markdown/Myst/Directives/Settings/SettingsBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Settings/SettingsBlock.cs @@ -43,8 +43,7 @@ public class SettingsBlock(DirectiveBlockParser parser, ParserContext context) : public int GroupHeadingLevel => _groupHeadingLevel ??= CalculateGroupHeadingLevel(); /// - public override IEnumerable GeneratedAnchors => - _generatedAnchors ??= LoadGeneratedAnchors(); + public override IEnumerable GeneratedAnchors => _generatedAnchors ??= LoadGeneratedAnchors(); /// Right-rail and in-page TOC entries for each settings group. public IEnumerable GeneratedTableOfContent @@ -56,18 +55,14 @@ public IEnumerable GeneratedTableOfContent var level = GroupHeadingLevel; return settings.Groups - .Where(g => ActiveDeploymentFilter is null || - DeploymentFilter.AnyVisible(g.Settings, ActiveDeploymentFilter, null)) - .Select(g => new PageTocItem - { - Heading = g.Name ?? string.Empty, - Slug = SettingsViewModel.GroupHeadingSlug(g), - Level = level - }).Where(t => !string.IsNullOrEmpty(t.Slug)); + .Where(g => ActiveDeploymentFilter is null || DeploymentFilter.AnyVisible(g.Settings, ActiveDeploymentFilter, null)) + .Select( + g => new PageTocItem { Heading = g.Name ?? string.Empty, Slug = SettingsViewModel.GroupHeadingSlug(g), Level = level } + ) + .Where(t => !string.IsNullOrEmpty(t.Slug)); } } - //TODO add all options from //https://mystmd.org/guide/directives#directive-include public override void FinalizeAndValidate(ParserContext context) @@ -140,9 +135,7 @@ private void ValidateDeploymentFilter() var trimmed = raw.Trim().ToLowerInvariant(); if (!DeploymentFilter.ValidValues.Contains(trimmed)) { - this.EmitWarning( - $"Unknown deployment filter '{raw}'. Valid values are: {string.Join(", ", DeploymentFilter.ValidValues)}." - ); + this.EmitWarning($"Unknown deployment filter '{raw}'. Valid values are: {string.Join(", ", DeploymentFilter.ValidValues)}."); return; } @@ -221,10 +214,8 @@ private int CalculateGroupHeadingLevel() var file = Build.ReadFileSystem.FileInfo.New(IncludePath); var yaml = file.FileSystem.File.ReadAllText(file.FullName); CollectSubstitutionUsageFromYaml(yaml, Build); - _parsedSettings = PrepareSettingsForRendering( - YamlSerialization.Deserialize(yaml, Build.ProductsConfiguration), - Context - ); + _parsedSettings = + PrepareSettingsForRendering(YamlSerialization.Deserialize(yaml, Build.ProductsConfiguration), Context); return _parsedSettings; } catch (YamlException) @@ -248,8 +239,7 @@ private static IEnumerable CollectSettingIds(YamlSettings yaml, string? foreach (var group in yaml.Groups) { - var groupVisible = deploymentFilter is null || - DeploymentFilter.AnyVisible(group.Settings, deploymentFilter, null); + var groupVisible = deploymentFilter is null || DeploymentFilter.AnyVisible(group.Settings, deploymentFilter, null); if (!groupVisible) continue; @@ -277,5 +267,3 @@ private static IEnumerable CollectSettingIds(Setting[] settings, string? } } } - - diff --git a/src/Elastic.Markdown/Myst/Directives/Settings/SettingsMarkdownNormalizer.cs b/src/Elastic.Markdown/Myst/Directives/Settings/SettingsMarkdownNormalizer.cs index fbd1e645d3..0997d0fbad 100644 --- a/src/Elastic.Markdown/Myst/Directives/Settings/SettingsMarkdownNormalizer.cs +++ b/src/Elastic.Markdown/Myst/Directives/Settings/SettingsMarkdownNormalizer.cs @@ -17,10 +17,12 @@ public static string Normalize(string markdown, string? product = null) var result = markdown.Replace("\r\n", "\n", StringComparison.Ordinal); if (result.Contains("[source,", StringComparison.Ordinal)) result = NormalizeAsciiDocSourceBlocks(result); - if (result.Contains("](/", StringComparison.Ordinal) + if ( + result.Contains("](/", StringComparison.Ordinal) || result.Contains("](docs-content://", StringComparison.Ordinal) || result.Contains("(elasticsearch://", StringComparison.Ordinal) - || result.Contains("(ecs://", StringComparison.Ordinal)) + || result.Contains("(ecs://", StringComparison.Ordinal) + ) result = RewriteReferenceLinksForDocset(result, product); return result; @@ -36,20 +38,11 @@ private static string RewriteReferenceLinksForDocset(string markdown, string? pr var referenceBase = string.Equals(product, "Kibana", StringComparison.OrdinalIgnoreCase) ? "https://www.elastic.co/docs/reference/kibana/" : "https://www.elastic.co/docs/reference/"; - s = RewriteParenLinksWithPrefix( - s, - "](/reference/", - referenceBase); - - s = RewriteSchemeLinks( - s, - "(elasticsearch://reference/", - "(https://www.elastic.co/docs/reference/"); - - return RewriteSchemeLinks( - s, - "(ecs://reference/", - "(https://www.elastic.co/docs/reference/"); + s = RewriteParenLinksWithPrefix(s, "](/reference/", referenceBase); + + s = RewriteSchemeLinks(s, "(elasticsearch://reference/", "(https://www.elastic.co/docs/reference/"); + + return RewriteSchemeLinks(s, "(ecs://reference/", "(https://www.elastic.co/docs/reference/"); } /// diff --git a/src/Elastic.Markdown/Myst/Directives/Settings/SettingsViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Settings/SettingsViewModel.cs index 050c142a2c..45f7e3ba4d 100644 --- a/src/Elastic.Markdown/Myst/Directives/Settings/SettingsViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Settings/SettingsViewModel.cs @@ -30,12 +30,10 @@ public class SettingsViewModel public string? ActiveDeploymentFilter { get; init; } public bool IsGroupVisible(SettingsGrouping group) => - ActiveDeploymentFilter is null || - DeploymentFilter.AnyVisible(group.Settings, ActiveDeploymentFilter, null); + ActiveDeploymentFilter is null || DeploymentFilter.AnyVisible(group.Settings, ActiveDeploymentFilter, null); public bool IsSettingVisible(Setting setting, ApplicableTo? inheritedAppliesTo) => - ActiveDeploymentFilter is null || - setting.IsVisibleForDeployment(ActiveDeploymentFilter, inheritedAppliesTo); + ActiveDeploymentFilter is null || setting.IsVisibleForDeployment(ActiveDeploymentFilter, inheritedAppliesTo); public string RenderAppliesToInline(ApplicableTo? appliesTo) => RenderAppliesToPlacement(appliesTo, ApplicabilityBadgePlacement.Combined); @@ -93,9 +91,7 @@ private string RenderAppliesToPlacement(ApplicableTo? appliesTo, ApplicabilityBa /// Stable HTML id / in-page TOC slug for a settings YAML group heading. public static string GroupHeadingSlug(SettingsGrouping group) => - string.IsNullOrWhiteSpace(group.Id) - ? (group.Name ?? string.Empty).Slugify() - : group.Id; + string.IsNullOrWhiteSpace(group.Id) ? (group.Name ?? string.Empty).Slugify() : group.Id; public static string ComposeSettingName(string? parentName, string? settingName) { @@ -112,7 +108,5 @@ public static string ComposeSettingName(string? parentName, string? settingName) /// Stable HTML fragment for a setting: YAML id when present, otherwise slugified composed name. public static string SettingFragmentId(Setting setting, string composedDisplayName) => - string.IsNullOrWhiteSpace(setting.Id) - ? composedDisplayName.Replace('.', '-').Slugify() - : setting.Id; + string.IsNullOrWhiteSpace(setting.Id) ? composedDisplayName.Replace('.', '-').Slugify() : setting.Id; } diff --git a/src/Elastic.Markdown/Myst/Directives/Settings/StructuredSettings.cs b/src/Elastic.Markdown/Myst/Directives/Settings/StructuredSettings.cs index 9ab3074305..de37c69bcc 100644 --- a/src/Elastic.Markdown/Myst/Directives/Settings/StructuredSettings.cs +++ b/src/Elastic.Markdown/Myst/Directives/Settings/StructuredSettings.cs @@ -82,8 +82,7 @@ public record Setting [YamlMember(Alias = "options")] public AllowedValue[]? Options { get; set; } - public ApplicableTo? ResolveAppliesTo(ApplicableTo? inheritedAppliesTo) => - AppliesTo ?? LegacyAppliesTo ?? inheritedAppliesTo; + public ApplicableTo? ResolveAppliesTo(ApplicableTo? inheritedAppliesTo) => AppliesTo ?? LegacyAppliesTo ?? inheritedAppliesTo; } [YamlSerializable] @@ -106,37 +105,40 @@ public enum SettingMutability public static class SettingDisplay { - public static string? FormatDefault(object? value) => - value switch - { - null => null, - string s => string.IsNullOrWhiteSpace(s) ? null : s, - bool b => b ? "true" : "false", - IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), - _ => value.ToString() - }; + public static string? FormatDefault(object? value) => value switch + { + null => null, + string s => string.IsNullOrWhiteSpace(s) ? null : s, + bool b => b ? "true" : "false", + IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString() + }; } public static class DeploymentFilter { /// Valid filter tokens accepted by the :deployment: directive option. - public static readonly IReadOnlySet ValidValues = - new HashSet(StringComparer.OrdinalIgnoreCase) { "ech", "ece", "eck", "self" }; + public static readonly IReadOnlySet ValidValues = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "ech", + "ece", + "eck", + "self" + }; /// /// Returns the for the given deployment filter key, /// mapping the canonical ech token to the ess model field. /// Returns null when the deployment type is not mentioned (i.e. not available). /// - public static AppliesCollection? GetForDeployment(this DeploymentApplicability deployment, string key) => - key.ToLowerInvariant() switch - { - "ech" => deployment.Ess, - "ece" => deployment.Ece, - "eck" => deployment.Eck, - "self" => deployment.Self, - _ => null - }; + public static AppliesCollection? GetForDeployment(this DeploymentApplicability deployment, string key) => key.ToLowerInvariant() switch + { + "ech" => deployment.Ess, + "ece" => deployment.Ece, + "eck" => deployment.Eck, + "self" => deployment.Self, + _ => null + }; /// /// Returns true when the setting should be shown for the given deployment filter. @@ -162,8 +164,9 @@ public static bool IsVisibleForDeployment(this Setting setting, string deploymen /// Returns true when at least one setting (recursively) in is visible. public static bool AnyVisible(Setting[] settings, string deploymentFilter, ApplicableTo? inheritedAppliesTo) => - settings.Any(s => - s.IsVisibleForDeployment(deploymentFilter, inheritedAppliesTo) || - AnyVisible(s.Settings, deploymentFilter, s.ResolveAppliesTo(inheritedAppliesTo)) + settings.Any( + s => + s.IsVisibleForDeployment(deploymentFilter, inheritedAppliesTo) || + AnyVisible(s.Settings, deploymentFilter, s.ResolveAppliesTo(inheritedAppliesTo)) ); } diff --git a/src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs index c91249a440..33d1a5b386 100644 --- a/src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs @@ -24,7 +24,10 @@ public class StepCrossNavigationLookupProvider : INavigationTraversable public static StepCrossNavigationLookupProvider Instance { get; } = new(); /// - public FrozenDictionary NavigationIndexedByOrder { get; } = new Dictionary().ToFrozenDictionary(); + public FrozenDictionary NavigationIndexedByOrder + { + get; + } = new Dictionary().ToFrozenDictionary(); /// public ConditionalWeakTable NavigationDocumentationFileLookup { get; } = []; diff --git a/src/Elastic.Markdown/Myst/Directives/Stepper/StepperBlock.cs b/src/Elastic.Markdown/Myst/Directives/Stepper/StepperBlock.cs index b9d936e7dd..a7570d9533 100644 --- a/src/Elastic.Markdown/Myst/Directives/Stepper/StepperBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Stepper/StepperBlock.cs @@ -55,8 +55,9 @@ private void AdjustInternalHeadings(StepBlock step, int stepLevel) Line = heading.Line + 1, Column = heading.Column, Length = heading.Level, - Message = $"Heading level h{heading.Level} inside a step renders at the same or higher level as the step itself (h{stepLevel}). " + - $"It has been adjusted to h{adjusted} — write it as '{hashes}' to avoid this hint." + Message = + $"Heading level h{heading.Level} inside a step renders at the same or higher level as the step itself (h{stepLevel}). " + + $"It has been adjusted to h{adjusted} — write it as '{hashes}' to avoid this hint." }); heading.Level = adjusted; } diff --git a/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookBlock.cs b/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookBlock.cs index fd9e9cf93c..82cdfdb0dc 100644 --- a/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Storybook/StorybookBlock.cs @@ -15,13 +15,11 @@ public class StorybookBlock(DirectiveBlockParser parser, ParserContext context) private static readonly TimeSpan RegistryFetchTimeout = TimeSpan.FromSeconds(30); // Shared across all storybook directives to pool connections; PooledConnectionLifetime bounds DNS staleness in long-lived serve/watch runs. - private static readonly HttpClient RegistryHttpClient = new( - new SocketsHttpHandler - { - AutomaticDecompression = DecompressionMethods.All, - PooledConnectionLifetime = TimeSpan.FromMinutes(5) - } - ) + private static readonly HttpClient RegistryHttpClient = new(new SocketsHttpHandler + { + AutomaticDecompression = DecompressionMethods.All, + PooledConnectionLifetime = TimeSpan.FromMinutes(5) + }) { Timeout = RegistryFetchTimeout }; public override string Directive => "storybook"; @@ -162,8 +160,8 @@ private bool TryLoadRegistry(out StorybookRegistry registry) // yet, so degrade to the committed default. A committed/static registry (no env fallback) that fails to read is // an authoring error and stays a hard error so typos and broken paths don't silently drop every embed. var fallback = Build.Configuration.StorybookRegistryFallback; - var hasEnvironmentFallback = !string.IsNullOrWhiteSpace(fallback) - && !string.Equals(fallback, rawRegistry, StringComparison.Ordinal); + var hasEnvironmentFallback = !string.IsNullOrWhiteSpace(fallback) && + !string.Equals(fallback, rawRegistry, StringComparison.Ordinal); if (!hasEnvironmentFallback) { this.EmitError($"storybook registry could not be read: {rawRegistry}", error); @@ -233,20 +231,21 @@ private bool TryDeserializeRegistry(string rawRegistryPath, string registryJson, var schemaVersion = RegistrySchemaVersion(registry.SchemaVersion); if (!schemaVersion.Equals(SupportedRegistrySchemaVersion, StringComparison.Ordinal)) { - this.EmitError($"storybook registry schemaVersion '{schemaVersion}' is not supported. Expected '{SupportedRegistrySchemaVersion}'."); + this.EmitError( + $"storybook registry schemaVersion '{schemaVersion}' is not supported. Expected '{SupportedRegistrySchemaVersion}'." + ); return false; } return true; } - private static string RegistrySchemaVersion(JsonElement schemaVersion) => - schemaVersion.ValueKind switch - { - JsonValueKind.Number => schemaVersion.GetRawText(), - JsonValueKind.String => schemaVersion.GetString() ?? string.Empty, - _ => string.Empty - }; + private static string RegistrySchemaVersion(JsonElement schemaVersion) => schemaVersion.ValueKind switch + { + JsonValueKind.Number => schemaVersion.GetRawText(), + JsonValueKind.String => schemaVersion.GetString() ?? string.Empty, + _ => string.Empty + }; private static StorybookRegistryStory? FindStory(StorybookRegistry registry, StoryReference reference) { @@ -260,9 +259,11 @@ private static string RegistrySchemaVersion(JsonElement schemaVersion) => var matches = registry.Stories .Where(story => MatchesReferenceScope(story.Key, story.Value, reference)) .Select(story => story.Value) - .Where(story => - story.DocsId?.Equals(reference.DocsId, StringComparison.OrdinalIgnoreCase) == true - || story.StorybookId?.Equals(reference.DocsId, StringComparison.OrdinalIgnoreCase) == true) + .Where( + story => + story.DocsId?.Equals(reference.DocsId, StringComparison.OrdinalIgnoreCase) == true || + story.StorybookId?.Equals(reference.DocsId, StringComparison.OrdinalIgnoreCase) == true + ) .ToArray(); return matches.Length == 1 ? matches[0] : null; @@ -271,15 +272,18 @@ private static string RegistrySchemaVersion(JsonElement schemaVersion) => private static bool MatchesReferenceScope(string registryId, StorybookRegistryStory story, StoryReference reference) { var parts = registryId.Split(':', 3, StringSplitOptions.TrimEntries); - if (!string.IsNullOrWhiteSpace(reference.Project) && (parts.Length != 3 || !parts[0].Equals(reference.Project, StringComparison.OrdinalIgnoreCase))) + if ( + !string.IsNullOrWhiteSpace(reference.Project) && + (parts.Length != 3 || !parts[0].Equals(reference.Project, StringComparison.OrdinalIgnoreCase)) + ) return false; if (string.IsNullOrWhiteSpace(reference.Storybook)) return true; var registryStorybook = parts.Length == 3 ? parts[1] : story.Alias; - return registryStorybook?.Equals(reference.Storybook, StringComparison.OrdinalIgnoreCase) == true - || story.Alias?.Equals(reference.Storybook, StringComparison.OrdinalIgnoreCase) == true; + return registryStorybook?.Equals(reference.Storybook, StringComparison.OrdinalIgnoreCase) == true || + story.Alias?.Equals(reference.Storybook, StringComparison.OrdinalIgnoreCase) == true; } private static bool IsSupportedRenderMode(string renderMode) => @@ -311,7 +315,14 @@ private static string SerializeBootstrap(string? baseUrl, StorybookRegistryBoots return trimmed; } - private sealed record StoryReference(string? Project, string? Storybook, string DocsId, string? Component, string? StoryName, string RawId) + private sealed record StoryReference( + string? Project, + string? Storybook, + string DocsId, + string? Component, + string? StoryName, + string RawId + ) { public static StoryReference FromId(string rawId, string? project, string? storybook, string? component, string? story) { diff --git a/src/Elastic.Markdown/Myst/Directives/SubPages/ListSubPagesBlock.cs b/src/Elastic.Markdown/Myst/Directives/SubPages/ListSubPagesBlock.cs index a10fbdae71..9cd841c48a 100644 --- a/src/Elastic.Markdown/Myst/Directives/SubPages/ListSubPagesBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/SubPages/ListSubPagesBlock.cs @@ -31,13 +31,14 @@ public override void FinalizeAndValidate(ParserContext context) var subPages = new List(); var sourcePath = context.MarkdownParentPath ?? context.MarkdownSourcePath; var document = context.TryFindDocument(sourcePath); - if (document is IDocumentationFile docFile && - context.NavigationTraversable.NavigationDocumentationFileLookup.TryGetValue(docFile, out var lookupResult)) + if ( + document is IDocumentationFile docFile && + context.NavigationTraversable.NavigationDocumentationFileLookup.TryGetValue(docFile, out var lookupResult) + ) { // When current page is the index of a node, lookup returns the node (not the leaf). Use that node's NavigationItems as siblings. - var parent = lookupResult is INodeNavigationItem indexNode && indexNode.Index.Model == docFile - ? indexNode - : lookupResult.Parent; + var parent = lookupResult is INodeNavigationItem indexNode && + indexNode.Index.Model == docFile ? indexNode : lookupResult.Parent; var currentUrl = lookupResult.Url; if (parent is not null) @@ -47,7 +48,8 @@ public override void FinalizeAndValidate(ParserContext context) var description = item switch { ILeafNavigationItem leaf => leaf.Model.Description, - INodeNavigationItem node when node.Index.Model is IDocumentationFile doc => doc.Description, + INodeNavigationItem node when node.Index.Model is IDocumentationFile doc => + doc.Description, _ => null }; diff --git a/src/Elastic.Markdown/Myst/Directives/Table/TableDirectiveBlock.cs b/src/Elastic.Markdown/Myst/Directives/Table/TableDirectiveBlock.cs index da4e2cd264..87f8923cf3 100644 --- a/src/Elastic.Markdown/Myst/Directives/Table/TableDirectiveBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Table/TableDirectiveBlock.cs @@ -91,8 +91,7 @@ public void ValidateTableColumnCount() if (ColumnWidths.Count > 0 && ColumnWidths.Count != columnCount) { - this.EmitError( - $"Column width count ({ColumnWidths.Count}) does not match table column count ({columnCount})."); + this.EmitError($"Column width count ({ColumnWidths.Count}) does not match table column count ({columnCount})."); ColumnWidths = []; } } @@ -118,7 +117,8 @@ private static int GetTableColumnCount(Markdig.Extensions.Tables.Table table) if (!int.TryParse(part, out var unit) || unit < 1 || unit > 12) { block.EmitError( - $"Invalid widths value '{value}'. Use preset (auto, description) or dash-separated integers 1-12 that sum to 12 (e.g., 4-8, 4-4-4)."); + $"Invalid widths value '{value}'. Use preset (auto, description) or dash-separated integers 1-12 that sum to 12 (e.g., 4-8, 4-4-4)." + ); return null; } diff --git a/src/Elastic.Markdown/Myst/Directives/Table/TableDirectiveViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Table/TableDirectiveViewModel.cs index 063f56f46b..b4c6a99b8a 100644 --- a/src/Elastic.Markdown/Myst/Directives/Table/TableDirectiveViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Table/TableDirectiveViewModel.cs @@ -38,15 +38,13 @@ public HtmlString RenderTableWithColumns() if (bracketEnd < 0) return new HtmlString(html.EnsureTrimmed()); - var colgroup = "" + - string.Join("", ColumnWidths.Select(w => string.Format(CultureInfo.InvariantCulture, "", w))) + - ""; + var colgroup = "" + + string.Join("", ColumnWidths.Select(w => string.Format(CultureInfo.InvariantCulture, "", w))) + + ""; var openingTag = html[tableIndex..bracketEnd]; var hasTableLayout = openingTag.Contains("table-layout", StringComparison.OrdinalIgnoreCase); - var newOpening = hasTableLayout - ? openingTag + ">" - : AppendTableLayoutFixed(openingTag); + var newOpening = hasTableLayout ? openingTag + ">" : AppendTableLayoutFixed(openingTag); var result = html[..tableIndex] + newOpening + colgroup + html[(bracketEnd + 1)..]; return new HtmlString(result.EnsureTrimmed()); @@ -79,7 +77,10 @@ private static string AppendTableLayoutFixed(string openingTag) var existingStyle = styleMatch.Groups[1].Value.TrimEnd(); var separator = string.IsNullOrEmpty(existingStyle) ? "" : "; "; var newStyle = existingStyle + separator + "table-layout:fixed"; - return openingTag[..styleMatch.Groups[1].Index] + newStyle + openingTag[(styleMatch.Groups[1].Index + styleMatch.Groups[1].Length)..] + ">"; + return openingTag[..styleMatch.Groups[1].Index] + + newStyle + + openingTag[(styleMatch.Groups[1].Index + styleMatch.Groups[1].Length)..] + + ">"; } return openingTag + " style=\"table-layout:fixed\">"; diff --git a/src/Elastic.Markdown/Myst/Directives/Tabs/TabSetBlock.cs b/src/Elastic.Markdown/Myst/Directives/Tabs/TabSetBlock.cs index cd5afc6d4b..ff876bc4d2 100644 --- a/src/Elastic.Markdown/Myst/Directives/Tabs/TabSetBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Tabs/TabSetBlock.cs @@ -7,8 +7,7 @@ namespace Elastic.Markdown.Myst.Directives.Tabs; -public class TabSetBlock(DirectiveBlockParser parser, ParserContext context) - : DirectiveBlock(parser, context) +public class TabSetBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context) { public override string Directive => "tab-set"; @@ -29,8 +28,7 @@ public int FindIndex() } } -public class TabItemBlock(DirectiveBlockParser parser, ParserContext context) - : DirectiveBlock(parser, context), IBlockTitle +public class TabItemBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context), IBlockTitle { public override string Directive => "tab-item"; @@ -57,5 +55,4 @@ public override void FinalizeAndValidate(ParserContext context) SyncKey = Prop("sync"); Selected = PropBool("selected"); } - } diff --git a/src/Elastic.Markdown/Myst/Directives/UnknownDirectiveBlock.cs b/src/Elastic.Markdown/Myst/Directives/UnknownDirectiveBlock.cs index db6697410f..587f2471d2 100644 --- a/src/Elastic.Markdown/Myst/Directives/UnknownDirectiveBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/UnknownDirectiveBlock.cs @@ -4,12 +4,9 @@ namespace Elastic.Markdown.Myst.Directives; -public class UnknownDirectiveBlock(DirectiveBlockParser parser, string directive, ParserContext context) - : DirectiveBlock(parser, context) +public class UnknownDirectiveBlock(DirectiveBlockParser parser, string directive, ParserContext context) : DirectiveBlock(parser, context) { public override string Directive => directive; - public override void FinalizeAndValidate(ParserContext context) - { - } + public override void FinalizeAndValidate(ParserContext context) { } } diff --git a/src/Elastic.Markdown/Myst/Directives/UnsupportedDirectiveBlock.cs b/src/Elastic.Markdown/Myst/Directives/UnsupportedDirectiveBlock.cs index 9938969e25..eb35d574de 100644 --- a/src/Elastic.Markdown/Myst/Directives/UnsupportedDirectiveBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/UnsupportedDirectiveBlock.cs @@ -6,13 +6,20 @@ namespace Elastic.Markdown.Myst.Directives; -public class UnsupportedDirectiveBlock(DirectiveBlockParser parser, string directive, int issueId, ParserContext context) - : DirectiveBlock(parser, context) +public class UnsupportedDirectiveBlock(DirectiveBlockParser parser, string directive, int issueId, ParserContext context) : DirectiveBlock( + parser, + context +) { public override string Directive => directive; public string IssueUrl => $"https://github.com/elastic/docs-builder/issues/{issueId}"; public override void FinalizeAndValidate(ParserContext context) => - context.EmitWarning(line: 1, column: 1, length: directive.Length, message: $"Directive block '{directive}' is unsupported. See {IssueUrl} for more information."); + context.EmitWarning( + line: 1, + column: 1, + length: directive.Length, + message: $"Directive block '{directive}' is unsupported. See {IssueUrl} for more information." + ); } diff --git a/src/Elastic.Markdown/Myst/Directives/Version/VersionBlock.cs b/src/Elastic.Markdown/Myst/Directives/Version/VersionBlock.cs index e01293cbd5..0cb528ee5c 100644 --- a/src/Elastic.Markdown/Myst/Directives/Version/VersionBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Version/VersionBlock.cs @@ -10,8 +10,7 @@ namespace Elastic.Markdown.Myst.Directives.Version; -public class VersionBlock(DirectiveBlockParser parser, string directive, ParserContext context) - : DirectiveBlock(parser, context) +public class VersionBlock(DirectiveBlockParser parser, string directive, ParserContext context) : DirectiveBlock(parser, context) { public override string Directive => directive; public string Class => directive.Replace("version", ""); diff --git a/src/Elastic.Markdown/Myst/FrontMatter/Products.cs b/src/Elastic.Markdown/Myst/FrontMatter/Products.cs index 5537061a73..0b7ebb6dd8 100644 --- a/src/Elastic.Markdown/Myst/FrontMatter/Products.cs +++ b/src/Elastic.Markdown/Myst/FrontMatter/Products.cs @@ -19,7 +19,10 @@ public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeseria if (parser.Current is Scalar) { var value = parser.Consume().Value; - throw new InvalidProductException($"Invalid YAML format. Products must be specified as a mapping with an 'id' field. Found scalar value: '{value}'. Example format:\nproducts:\n - id: apm", products); + throw new InvalidProductException( + $"Invalid YAML format. Products must be specified as a mapping with an 'id' field. Found scalar value: '{value}'. Example format:\nproducts:\n - id: apm", + products + ); } _ = parser.Consume(); @@ -48,8 +51,11 @@ public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeseria public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => serializer.Invoke(value, type); } -public class InvalidProductException(string invalidValue, ProductsConfiguration products) - : Exception( - $"Invalid products frontmatter value: \"{invalidValue}\"." + - (!string.IsNullOrWhiteSpace(invalidValue) ? " " + new Suggestion(products.PublicReferenceProducts.Select(p => p.Value.Id).ToHashSet(), invalidValue).GetSuggestionQuestion() : "") + - "\nYou can find the full list at https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/syntax/frontmatter#products."); +public class InvalidProductException(string invalidValue, ProductsConfiguration products) : Exception( + $"Invalid products frontmatter value: \"{invalidValue}\"." + + (!string.IsNullOrWhiteSpace(invalidValue) + ? " " + + new Suggestion(products.PublicReferenceProducts.Select(p => p.Value.Id).ToHashSet(), invalidValue).GetSuggestionQuestion() + : "") + + "\nYou can find the full list at https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/syntax/frontmatter#products." +); diff --git a/src/Elastic.Markdown/Myst/InlineParsers/AutoLinkInlineParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/AutoLinkInlineParser.cs index 16cb3fc04d..f9e4c1529c 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/AutoLinkInlineParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/AutoLinkInlineParser.cs @@ -88,7 +88,11 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice) // Emit hint for elastic.co/docs URLs (after setting Inline so position is correct) if (url.Contains("elastic.co/docs", StringComparison.OrdinalIgnoreCase)) - processor.EmitHint(linkInline, HintType.AutolinkElasticCoDocs, "Autolink points to elastic.co/docs. Consider using a crosslink or relative link instead."); + processor.EmitHint( + linkInline, + HintType.AutolinkElasticCoDocs, + "Autolink points to elastic.co/docs. Consider using a crosslink or relative link instead." + ); // Advance the slice past the URL var end = slice.Start + urlLength; diff --git a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs index cf3acf5f93..89b36c9ceb 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs @@ -67,7 +67,6 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice) return match; } - private static void ParseStylingInstructions(LinkInline link, ParserContext context) { if (!link.IsImage) @@ -99,8 +98,7 @@ private static void ParseStylingInstructions(LinkInline link, ParserContext cont link.Title = title.ReplaceSubstitutions(context); } - private static bool IsInCommentBlock(LinkInline link) => - link.Parent?.ParentBlock is CommentBlock; + private static bool IsInCommentBlock(LinkInline link) => link.Parent?.ParentBlock is CommentBlock; private static void ValidateAndProcessLink(LinkInline link, InlineProcessor processor, ParserContext context) { @@ -111,7 +109,8 @@ private static void ValidateAndProcessLink(LinkInline link, InlineProcessor proc var replacedUrl = url.ReplaceSubstitutions(processor.GetContext()); if (replacedUrl.Contains("{{")) { - processor.EmitError(link, + processor.EmitError( + link, $"The url contains unresolved template expressions: '{replacedUrl}'. Please check if there is an appropriate global or frontmatter subs variable." ); return; @@ -119,7 +118,10 @@ private static void ValidateAndProcessLink(LinkInline link, InlineProcessor proc if (!replacedUrl.StartsWith("http")) { - processor.EmitError(link, $"Link is resolved to '{replacedUrl}'. Only external links are allowed to be resolved from template expressions."); + processor.EmitError( + link, + $"Link is resolved to '{replacedUrl}'. Only external links are allowed to be resolved from template expressions." + ); return; } url = replacedUrl; @@ -175,17 +177,10 @@ private static bool ValidateExternalUri(LinkInline link, InlineProcessor process return false; var hostParts = uri.Host.Split('.'); - var baseDomain = uri.Host == "localhost" - ? "localhost" - : hostParts.Length >= 2 - ? string.Join('.', hostParts[^2..]) - : uri.Host; + var baseDomain = uri.Host == "localhost" ? "localhost" : hostParts.Length >= 2 ? string.Join('.', hostParts[^2..]) : uri.Host; if (uri.Scheme == "mailto" && baseDomain != "elastic.co") { - processor.EmitWarning( - link, - $"mailto links should be to elastic.co domains. Found {uri.Host} in {link.Url}. " - ); + processor.EmitWarning(link, $"mailto links should be to elastic.co domains. Found {uri.Host} in {link.Url}. "); } return true; @@ -197,9 +192,7 @@ private static void ProcessCrossLink(LinkInline link, InlineProcessor processor, if (url != null) context.Build.Collector.EmitCrossLink(url); - if (context.CrossLinkResolver.TryResolve( - s => processor.EmitError(link, s), - uri, out var resolvedUri)) + if (context.CrossLinkResolver.TryResolve(s => processor.EmitError(link, s), uri, out var resolvedUri)) { link.Url = resolvedUri.ToString(); if (resolvedUri.IsAbsoluteUri && context.Build.BuildType == BuildType.Isolated) @@ -247,17 +240,18 @@ private static void ProcessInternalLink(LinkInline link, InlineProcessor process { //TODO make this an error once all offending repositories have been updated if (!file.Directory!.FullName.StartsWith(currentMarkdown.ScopeDirectory.FullName + Path.DirectorySeparatorChar)) - processor.EmitHint(link, $"Image '{url}' is referenced out of table of contents scope '{currentMarkdown.ScopeDirectory}'."); + processor.EmitHint( + link, + $"Image '{url}' is referenced out of table of contents scope '{currentMarkdown.ScopeDirectory}'." + ); } } - var linkMarkdown = context.TryFindDocument(file) as MarkdownFile; if (linkMarkdown is not null) { if (context.NavigationTraversable.NavigationDocumentationFileLookup.TryGetValue(linkMarkdown, out var navigationLookup)) link.SetData("TargetNavigationRoot", navigationLookup.NavigationRoot); - } return linkMarkdown; } @@ -269,33 +263,37 @@ private static (string url, string? anchor) SplitUrlAndAnchor(string fullUrl) } private static string GetIncludeFromPath(string url, ParserContext context) => - url.StartsWith('/') - ? context.Build.DocumentationSourceDirectory.FullName - : context.MarkdownSourcePath.Directory!.FullName; - - private static void ValidateInternalUrl(InlineProcessor processor, string url, string includeFrom, LinkInline link, ParserContext context) + url.StartsWith('/') ? context.Build.DocumentationSourceDirectory.FullName : context.MarkdownSourcePath.Directory!.FullName; + + private static void ValidateInternalUrl( + InlineProcessor processor, + string url, + string includeFrom, + LinkInline link, + ParserContext context + ) { if (string.IsNullOrWhiteSpace(url)) return; - var pathOnDisk = Path.GetFullPath(Path.Join(includeFrom, url.TrimStart('/'))); // Synthetic files (e.g. generated CLI reference pages) don't exist on disk but ARE registered in the documentation set var relativeToSource = Path.GetRelativePath(context.Build.DocumentationSourceDirectory.FullName, pathOnDisk); var existsInSet = context.TryFindDocumentByRelativePath(relativeToSource) is not null; if (!context.Build.ReadFileSystem.File.Exists(pathOnDisk) && !existsInSet) { - if (context.Configuration.Redirects is not null && context.Configuration.Redirects.TryGetValue(url.TrimStart('/'), out var redirect)) + if ( + context.Configuration.Redirects is not null && + context.Configuration.Redirects.TryGetValue(url.TrimStart('/'), out var redirect) + ) { var name = redirect.To ?? - (redirect.Many is not null - ? $"one of: {string.Join(", ", redirect.Many.Select(m => m.To))}" - : "unknown" - ); + (redirect.Many is not null ? $"one of: {string.Join(", ", redirect.Many.Select(m => m.To))}" : "unknown"); processor.EmitWarning(link, $"Local file `{url}` has a redirect, please update this reference to: {name}"); } - else if (!url.EndsWith(".md", StringComparison.OrdinalIgnoreCase) - && context.Build.ReadFileSystem.File.Exists(pathOnDisk + ".md")) + else if ( + !url.EndsWith(".md", StringComparison.OrdinalIgnoreCase) && context.Build.ReadFileSystem.File.Exists(pathOnDisk + ".md") + ) { processor.EmitError(link, $"`{url}` is not a valid internal link. Did you forget to add the .md extension?"); } @@ -306,15 +304,24 @@ private static void ValidateInternalUrl(InlineProcessor processor, string url, s } } - private static void ProcessLinkText(InlineProcessor processor, LinkInline link, MarkdownFile? markdown, string? anchor, string url, IFileInfo file) + private static void ProcessLinkText( + InlineProcessor processor, + LinkInline link, + MarkdownFile? markdown, + string? anchor, + string url, + IFileInfo file + ) { if (link.FirstChild != null && string.IsNullOrEmpty(anchor)) return; if (markdown is null && link.FirstChild == null) { - processor.EmitWarning(link, - $"'{url}' could not be resolved to a markdown file while creating an auto text link, '{file.FullName}' does not exist."); + processor.EmitWarning( + link, + $"'{url}' could not be resolved to a markdown file while creating an auto text link, '{file.FullName}' does not exist." + ); return; } @@ -336,7 +343,10 @@ public static IFileInfo ResolveFile(ParserContext context, string url) => string.IsNullOrWhiteSpace(url) ? context.MarkdownSourcePath : url.StartsWith('/') - ? context.Build.ReadFileSystem.FileInfo.New(Path.Join(context.Build.DocumentationSourceDirectory.FullName, url.TrimStart('/'))) + ? context.Build + .ReadFileSystem + .FileInfo + .New(Path.Join(context.Build.DocumentationSourceDirectory.FullName, url.TrimStart('/'))) : context.Build.ReadFileSystem.FileInfo.New(Path.Join(context.MarkdownSourcePath.Directory!.FullName, url)); private static void ValidateAnchor(InlineProcessor processor, MarkdownFile markdown, string anchor, LinkInline link) @@ -350,8 +360,10 @@ private static void UpdateLinkUrl(LinkInline link, MarkdownFile? linkMarkdown, s var newUrl = url; if (linkMarkdown is not null) { - if (context.NavigationTraversable.NavigationDocumentationFileLookup.TryGetValue(linkMarkdown, out var navigationLookup) - && !string.IsNullOrEmpty(navigationLookup.Url)) + if ( + context.NavigationTraversable.NavigationDocumentationFileLookup.TryGetValue(linkMarkdown, out var navigationLookup) && + !string.IsNullOrEmpty(navigationLookup.Url) + ) { // Navigation URLs are absolute and start with / // Apply the same prefix handling as UpdateRelativeUrl would for absolute paths @@ -364,7 +376,6 @@ private static void UpdateLinkUrl(LinkInline link, MarkdownFile? linkMarkdown, s else newUrl = UpdateRelativeUrl(context, url); - if (newUrl.EndsWith(".md")) { newUrl = newUrl.EndsWith($"{Path.DirectorySeparatorChar}index.md") @@ -376,11 +387,7 @@ private static void UpdateLinkUrl(LinkInline link, MarkdownFile? linkMarkdown, s if (newUrl.EndsWith(".toml")) newUrl = newUrl[..^5]; - link.Url = !string.IsNullOrEmpty(anchor) - ? newUrl == context.CurrentUrlPath - ? $"#{anchor}" - : $"{newUrl}#{anchor}" - : newUrl; + link.Url = !string.IsNullOrEmpty(anchor) ? newUrl == context.CurrentUrlPath ? $"#{anchor}" : $"{newUrl}#{anchor}" : newUrl; } // TODO revisit when we refactor our documentation set graph @@ -399,7 +406,10 @@ public static string UpdateRelativeUrl(ParserContext context, string url) var path = Path.GetFullPath(fi.FileSystem.Path.Join(fi.Directory!.FullName, newUrl)); var pathInfo = fi.FileSystem.FileInfo.New(path); pathInfo = pathInfo.EnsureSubPathOf(context.Configuration.ScopeDirectory, newUrl); - var relativePath = fi.FileSystem.Path.GetRelativePath(context.Configuration.ScopeDirectory.FullName, pathInfo.FullName).OptionalWindowsReplace(); + var relativePath = fi.FileSystem + .Path + .GetRelativePath(context.Configuration.ScopeDirectory.FullName, pathInfo.FullName) + .OptionalWindowsReplace(); // if we are trying to resolve a relative url from a _snippet folder ensure we eat the _snippet folder // as it's not part of url by chopping of the extra parent navigation @@ -442,7 +452,9 @@ public static string UpdateRelativeUrl(ParserContext context, string url) newUrl = uri.AbsolutePath; } else - context.EmitError($"Failed to acquire navigation for current markdown file '{currentMarkdown.FileName}' while resolving relative url '{url}'."); + context.EmitError( + $"Failed to acquire navigation for current markdown file '{currentMarkdown.FileName}' while resolving relative url '{url}'." + ); } // When running on Windows, path traversal results must be normalized prior to being used in a URL @@ -460,10 +472,8 @@ public static string UpdateRelativeUrl(ParserContext context, string url) return newUrl; } - private static bool IsCrossLink([NotNullWhen(true)] Uri? uri) => - CrossLinkValidator.IsCrossLink(uri); + private static bool IsCrossLink([NotNullWhen(true)] Uri? uri) => CrossLinkValidator.IsCrossLink(uri); private static bool IsPassthroughCustomProtocolScheme(string scheme) => - scheme.Equals("cursor", StringComparison.OrdinalIgnoreCase) - || scheme.StartsWith("vscode", StringComparison.OrdinalIgnoreCase); + scheme.Equals("cursor", StringComparison.OrdinalIgnoreCase) || scheme.StartsWith("vscode", StringComparison.OrdinalIgnoreCase); } diff --git a/src/Elastic.Markdown/Myst/InlineParsers/HardBreakParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/HardBreakParser.cs index 1ec836ed66..a7a783615d 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/HardBreakParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/HardBreakParser.cs @@ -24,8 +24,7 @@ public static MarkdownPipelineBuilder UseHardBreaks(this MarkdownPipelineBuilder public class HardBreakBuilderExtension : IMarkdownExtension { - public void Setup(MarkdownPipelineBuilder pipeline) => - pipeline.InlineParsers.InsertBefore(new HardBreakParser()); + public void Setup(MarkdownPipelineBuilder pipeline) => pipeline.InlineParsers.InsertBefore(new HardBreakParser()); public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) => renderer.ObjectRenderers.InsertAfter(new HardBreakRenderer()); @@ -60,6 +59,5 @@ public class HardBreak : LeafInline; public class HardBreakRenderer : HtmlObjectRenderer { - protected override void Write(HtmlRenderer renderer, HardBreak obj) => - renderer.Write("
"); + protected override void Write(HtmlRenderer renderer, HardBreak obj) => renderer.Write("
"); } diff --git a/src/Elastic.Markdown/Myst/InlineParsers/HeadingBlockWithSlugParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/HeadingBlockWithSlugParser.cs index 14b4cf445a..bc766629f0 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/HeadingBlockWithSlugParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/HeadingBlockWithSlugParser.cs @@ -43,7 +43,9 @@ public override bool Close(BlockProcessor processor, Block block) var text = headingBlock.Lines.Lines[0].Slice.AsSpan(); if (AppliesToSyntax.IsMatch(text)) - processor.EmitWarning("Do not use inline 'applies_to' annotations with headings. Use a section 'applies_to' annotation instead."); + processor.EmitWarning( + "Do not use inline 'applies_to' annotations with headings. Use a section 'applies_to' annotation instead." + ); // Remove icon syntax from the heading text var cleanText = IconSyntax.Replace(text.ToString(), "").Trim(); diff --git a/src/Elastic.Markdown/Myst/InlineParsers/InlineAnchorParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/InlineAnchorParser.cs index bda0b810ea..9c8291e6ad 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/InlineAnchorParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/InlineAnchorParser.cs @@ -58,8 +58,6 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice) return true; } - - } public class InlineAnchor : LeafInline @@ -69,6 +67,5 @@ public class InlineAnchor : LeafInline public class InlineAnchorRenderer : HtmlObjectRenderer { - protected override void Write(HtmlRenderer renderer, InlineAnchor obj) => - renderer.Write(""); + protected override void Write(HtmlRenderer renderer, InlineAnchor obj) => renderer.Write(""); } diff --git a/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionMutationHelper.cs b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionMutationHelper.cs index 6383623d98..80ce7d4cb7 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionMutationHelper.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionMutationHelper.cs @@ -97,13 +97,12 @@ private static (bool Success, string Result) TryGetVersion(string version, Func< } // These methods match the exact implementation in SubstitutionRenderer - private static string Capitalize(string input) => - input switch - { - null => string.Empty, - "" => string.Empty, - _ => string.Concat(input[0].ToString().ToUpper(), input.AsSpan(1)) - }; + private static string Capitalize(string input) => input switch + { + null => string.Empty, + "" => string.Empty, + _ => string.Concat(input[0].ToString().ToUpper(), input.AsSpan(1)) + }; private static string ToKebabCase(string str) => JsonNamingPolicy.KebabCaseLower.ConvertName(str).Replace(" ", string.Empty); diff --git a/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs index 35cd678b8f..08e295a0a9 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs @@ -18,8 +18,7 @@ namespace Elastic.Markdown.Myst.InlineParsers.Substitution; [DebuggerDisplay("{GetType().Name} Line: {Line}, Found: {Found}, Replacement: {Replacement}")] -public class SubstitutionLeaf(string content, bool found, string replacement) - : CodeInline(content) +public class SubstitutionLeaf(string content, bool found, string replacement) : CodeInline(content) { public bool Found { get; } = found; public string Replacement { get; } = replacement; @@ -29,20 +28,34 @@ public class SubstitutionLeaf(string content, bool found, string replacement) [EnumExtensions] public enum SubstitutionMutation { - [Display(Name = "M")] MajorComponent, - [Display(Name = "M.x")] MajorX, - [Display(Name = "M.M")] MajorMinor, - [Display(Name = "M+1")] IncreaseMajor, - [Display(Name = "M.M+1")] IncreaseMinor, - [Display(Name = "lc")] LowerCase, - [Display(Name = "uc")] UpperCase, - [Display(Name = "tc")] TitleCase, - [Display(Name = "c")] Capitalize, - [Display(Name = "kc")] KebabCase, - [Display(Name = "sc")] SnakeCase, - [Display(Name = "cc")] CamelCase, - [Display(Name = "pc")] PascalCase, - [Display(Name = "trim")] Trim + [Display(Name = "M")] + MajorComponent, + [Display(Name = "M.x")] + MajorX, + [Display(Name = "M.M")] + MajorMinor, + [Display(Name = "M+1")] + IncreaseMajor, + [Display(Name = "M.M+1")] + IncreaseMinor, + [Display(Name = "lc")] + LowerCase, + [Display(Name = "uc")] + UpperCase, + [Display(Name = "tc")] + TitleCase, + [Display(Name = "c")] + Capitalize, + [Display(Name = "kc")] + KebabCase, + [Display(Name = "sc")] + SnakeCase, + [Display(Name = "cc")] + CamelCase, + [Display(Name = "pc")] + PascalCase, + [Display(Name = "trim")] + Trim } public class SubstitutionRenderer : HtmlObjectRenderer @@ -131,9 +144,12 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice) // URL templates like `https://tiles.../{{z}}/{{x}}/{{y}}.png` must not be parsed as substitutions // unless the key is a defined docset/front-matter substitution (for example `{{x}}` used intentionally). - if (key.Length <= 1 && mutationStrings.Length == 0 + if ( + key.Length <= 1 + && mutationStrings.Length == 0 && !context.Substitutions.ContainsKey(key) - && !context.ContextSubstitutions.ContainsKey(key)) + && !context.ContextSubstitutions.ContainsKey(key) + ) return false; if (context.Substitutions.TryGetValue(key, out var value)) @@ -163,12 +179,23 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice) if (!found) // We temporarily diagnose variable spaces as hints. We used to not read this at all. - processor.Emit(key.Contains(' ') ? Severity.Hint : Severity.Error, line + 1, column + 3, substitutionLeaf.Span.Length - 3, $"Substitution key {{{key}}} is undefined"); + processor.Emit( + key.Contains(' ') ? Severity.Hint : Severity.Error, + line + 1, + column + 3, + substitutionLeaf.Span.Length - 3, + $"Substitution key {{{key}}} is undefined" + ); else { List? mutations = null; if (mutationStrings.Length >= 10) - processor.EmitError(line + 1, column + 3, substitutionLeaf.Span.Length - 3, $"Substitution key {{{key}}} defines too many mutations, none will be applied"); + processor.EmitError( + line + 1, + column + 3, + substitutionLeaf.Span.Length - 3, + $"Substitution key {{{key}}} defines too many mutations, none will be applied" + ); else if (mutationStrings.Length > 0) { foreach (var mutationStr in mutationStrings) @@ -181,19 +208,22 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice) mutations.Add(mutation); } else - processor.EmitError(line + 1, column + 3, substitutionLeaf.Span.Length - 3, $"Mutation '{trimmedMutation}' on {{{key}}} is undefined"); + processor.EmitError( + line + 1, + column + 3, + substitutionLeaf.Span.Length - 3, + $"Mutation '{trimmedMutation}' on {{{key}}} is undefined" + ); } } substitutionLeaf.Mutations = mutations; } - if (processor.TrackTrivia) { // startPosition and slice.Start include the opening/closing sticks. - substitutionLeaf.ContentWithTrivia = - new StringSlice(slice.Text, startPosition + openSticks, slice.Start - openSticks - 1); + substitutionLeaf.ContentWithTrivia = new StringSlice(slice.Text, startPosition + openSticks, slice.Start - openSticks - 1); } processor.Inline = substitutionLeaf; diff --git a/src/Elastic.Markdown/Myst/InlineParsers/SubstitutionInlineCode/SubstitutionInlineCodeParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/SubstitutionInlineCode/SubstitutionInlineCodeParser.cs index 47d16deea9..66449ab7ee 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/SubstitutionInlineCode/SubstitutionInlineCodeParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/SubstitutionInlineCode/SubstitutionInlineCodeParser.cs @@ -82,7 +82,12 @@ private static string ProcessSubstitutions(string content, ParserContext context { if (mutationStrings.Length >= 10) { - processor.EmitError(line + 1, column + match.Index, match.Length, $"Substitution key {{{key}}} defines too many mutations, none will be applied"); + processor.EmitError( + line + 1, + column + match.Index, + match.Length, + $"Substitution key {{{key}}} defines too many mutations, none will be applied" + ); replacement = value; // Use original value without mutations } else @@ -94,7 +99,12 @@ private static string ProcessSubstitutions(string content, ParserContext context if (SubstitutionMutationExtensions.TryParse(trimmedMutation, out var mutation, true, true)) mutations.Add(mutation); else - processor.EmitError(line + 1, column + match.Index, match.Length, $"Mutation '{trimmedMutation}' on {{{key}}} is undefined"); + processor.EmitError( + line + 1, + column + match.Index, + match.Length, + $"Mutation '{trimmedMutation}' on {{{key}}} is undefined" + ); } if (mutations.Count > 0) @@ -107,7 +117,13 @@ private static string ProcessSubstitutions(string content, ParserContext context else { // We temporarily diagnose variable spaces as hints. We used to not read this at all. - processor.Emit(key.Contains(' ') ? Severity.Hint : Severity.Error, line + 1, column + match.Index, match.Length, $"Substitution key {{{key}}} is undefined"); + processor.Emit( + key.Contains(' ') ? Severity.Hint : Severity.Error, + line + 1, + column + match.Index, + match.Length, + $"Substitution key {{{key}}} is undefined" + ); } } diff --git a/src/Elastic.Markdown/Myst/Linters/SpaceNormalizer.cs b/src/Elastic.Markdown/Myst/Linters/SpaceNormalizer.cs index 4c4a8b6a72..a7df69a9cd 100644 --- a/src/Elastic.Markdown/Myst/Linters/SpaceNormalizer.cs +++ b/src/Elastic.Markdown/Myst/Linters/SpaceNormalizer.cs @@ -40,30 +40,50 @@ public class SpaceNormalizerParser : InlineParser private static readonly char[] CharactersToRemove = [ '\u000B', // Line Tabulation (\v) - + '\u000C', // Form Feed (\f) - + '\u0085', // Next Line + '\u1680', // Ogham Space Mark + '\u180E', // Mongolian Vowel Separator - + '\ufeff', // Zero Width No-Break Space - + '\u200B', // Zero Width Space - + '\u2028', // Line Separator - '\u2029' // Paragraph Separator + + '\u2029' // Paragraph Separator + ]; // Characters to replace with regular spaces (visible but problematic) private static readonly char[] CharactersToReplace = [ '\u2000', // En Quad + '\u2001', // Em Quad + '\u2002', // En Space - + '\u2003', // Em Space - + '\u2004', // Tree-Per-Em + '\u2005', // Four-Per-Em + '\u2006', // Six-Per-Em + '\u2008', // Punctuation Space - + '\u2009', // Thin Space + '\u200A', // Hair Space - '\u3000' // Ideographic Space + + '\u3000' // Ideographic Space + ]; // Combined list of characters that need fixing (removed or replaced) @@ -92,7 +112,11 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice) if (!FilesWithHintEmitted.Contains(filePath)) { _ = FilesWithHintEmitted.Add(filePath); - processor.EmitHint(processor.Inline, 1, "Irregular space detected. Run 'docs-builder format --write' to automatically fix all instances."); + processor.EmitHint( + processor.Inline, + 1, + "Irregular space detected. Run 'docs-builder format --write' to automatically fix all instances." + ); } } @@ -108,6 +132,5 @@ public class IrregularSpace : LeafInline public class SpaceNormalizerRenderer : HtmlObjectRenderer { - protected override void Write(HtmlRenderer renderer, IrregularSpace obj) => - renderer.Write(' '); + protected override void Write(HtmlRenderer renderer, IrregularSpace obj) => renderer.Write(' '); } diff --git a/src/Elastic.Markdown/Myst/MarkdownParser.cs b/src/Elastic.Markdown/Myst/MarkdownParser.cs index bdcb9e68cc..3940d10fee 100644 --- a/src/Elastic.Markdown/Myst/MarkdownParser.cs +++ b/src/Elastic.Markdown/Myst/MarkdownParser.cs @@ -37,8 +37,7 @@ public partial class MarkdownParser(BuildContext build, IParserResolvers resolve public Task ParseAsync(IFileInfo path, YamlFrontMatter? matter, Cancel ctx) => ParseFromFile(path, matter, Pipeline, false, ctx); - public Task MinimalParseAsync(IFileInfo path, Cancel ctx) => - ParseFromFile(path, null, MinimalPipeline, true, ctx); + public Task MinimalParseAsync(IFileInfo path, Cancel ctx) => ParseFromFile(path, null, MinimalPipeline, true, ctx); private Task ParseFromFile(IFileInfo path, YamlFrontMatter? matter, MarkdownPipeline pipeline, bool skip, Cancel ctx) { @@ -69,25 +68,57 @@ public MarkdownDocument ParseApiDescriptionString(string markdown, IFileInfo pat public MarkdownDocument MinimalParseStringAsync(string markdown, IFileInfo path, YamlFrontMatter? matter) => ParseMarkdownStringAsync(markdown, path, matter, MinimalPipeline); - public MarkdownDocument MinimalParseStringAsync(string markdown, IFileInfo path, YamlFrontMatter? matter, IFileInfo? originalSourcePath) => - ParseMarkdownStringAsync(markdown, path, matter, originalSourcePath, MinimalPipeline); - - private MarkdownDocument ParseMarkdownStringAsync(string markdown, IFileInfo path, YamlFrontMatter? matter, MarkdownPipeline pipeline) => - ParseMarkdownStringAsync(Build, Resolvers, markdown, path, matter, null, pipeline); - - private MarkdownDocument ParseMarkdownStringAsync(string markdown, IFileInfo path, YamlFrontMatter? matter, IFileInfo? originalSourcePath, MarkdownPipeline pipeline) => - ParseMarkdownStringAsync(Build, Resolvers, markdown, path, matter, originalSourcePath, pipeline); - - public static MarkdownDocument ParseMarkdownStringAsync(BuildContext build, IParserResolvers resolvers, string markdown, IFileInfo path, - YamlFrontMatter? matter, MarkdownPipeline pipeline) => - ParseMarkdownStringAsync(build, resolvers, markdown, path, matter, null, pipeline); + public MarkdownDocument MinimalParseStringAsync( + string markdown, + IFileInfo path, + YamlFrontMatter? matter, + IFileInfo? originalSourcePath + ) => ParseMarkdownStringAsync(markdown, path, matter, originalSourcePath, MinimalPipeline); - public static MarkdownDocument ParseMarkdownStringAsync(BuildContext build, IParserResolvers resolvers, string markdown, IFileInfo path, - YamlFrontMatter? matter, IFileInfo? originalSourcePath, MarkdownPipeline pipeline) => - ParseMarkdownStringAsync(build, resolvers, markdown, path, matter, originalSourcePath, pipeline, skipValidation: false); + private MarkdownDocument ParseMarkdownStringAsync( + string markdown, + IFileInfo path, + YamlFrontMatter? matter, + MarkdownPipeline pipeline + ) => ParseMarkdownStringAsync(Build, Resolvers, markdown, path, matter, null, pipeline); - public static MarkdownDocument ParseMarkdownStringAsync(BuildContext build, IParserResolvers resolvers, string markdown, IFileInfo path, - YamlFrontMatter? matter, IFileInfo? originalSourcePath, MarkdownPipeline pipeline, bool skipValidation) + private MarkdownDocument ParseMarkdownStringAsync( + string markdown, + IFileInfo path, + YamlFrontMatter? matter, + IFileInfo? originalSourcePath, + MarkdownPipeline pipeline + ) => ParseMarkdownStringAsync(Build, Resolvers, markdown, path, matter, originalSourcePath, pipeline); + + public static MarkdownDocument ParseMarkdownStringAsync( + BuildContext build, + IParserResolvers resolvers, + string markdown, + IFileInfo path, + YamlFrontMatter? matter, + MarkdownPipeline pipeline + ) => ParseMarkdownStringAsync(build, resolvers, markdown, path, matter, null, pipeline); + + public static MarkdownDocument ParseMarkdownStringAsync( + BuildContext build, + IParserResolvers resolvers, + string markdown, + IFileInfo path, + YamlFrontMatter? matter, + IFileInfo? originalSourcePath, + MarkdownPipeline pipeline + ) => ParseMarkdownStringAsync(build, resolvers, markdown, path, matter, originalSourcePath, pipeline, skipValidation: false); + + public static MarkdownDocument ParseMarkdownStringAsync( + BuildContext build, + IParserResolvers resolvers, + string markdown, + IFileInfo path, + YamlFrontMatter? matter, + IFileInfo? originalSourcePath, + MarkdownPipeline pipeline, + bool skipValidation + ) { var state = new ParserState(build) { @@ -109,8 +140,15 @@ public static MarkdownDocument ParseMarkdownStringAsync(BuildContext build, IPar return Markdig.Markdown.Parse(preprocessedMarkdown, pipeline, context); } - public static Task ParseSnippetAsync(BuildContext build, IParserResolvers resolvers, IFileInfo path, IFileInfo parentPath, - YamlFrontMatter? matter, Cancel ctx, int? includeLine = null) + public static Task ParseSnippetAsync( + BuildContext build, + IParserResolvers resolvers, + IFileInfo path, + IFileInfo parentPath, + YamlFrontMatter? matter, + Cancel ctx, + int? includeLine = null + ) { var state = new ParserState(build) { @@ -128,12 +166,12 @@ public static Task ParseSnippetAsync(BuildContext build, IPars return ParseAsync(path, context, Pipeline, ctx); } - private static async Task ParseAsync( IFileInfo path, MarkdownParserContext context, MarkdownPipeline pipeline, - Cancel ctx) + Cancel ctx + ) { string inputMarkdown; if (path.FileSystem is FileSystem) @@ -161,6 +199,7 @@ private static MarkdownPipeline MinimalPipeline var builder = new MarkdownPipelineBuilder() .UseYamlFrontMatter() .UseFootnotes() // Must match Pipeline to avoid inconsistent footnote handling + .UseInlineAnchors() .UseHeadingsWithSlugs() .UseDirectives(); @@ -183,6 +222,7 @@ public static MarkdownPipeline Pipeline .UseInlineAnchors() .UsePreciseSourceLocation() .UseFootnotes() // Must be before UseDiagnosticLinks to ensure FootnoteLinkParser is inserted correctly + .UseDiagnosticLinks() .UseAutoLinks() .UseHeadingsWithSlugs() @@ -228,28 +268,31 @@ private static string PreprocessLinkSubstitutions(string markdown, ParserContext // Find all code block boundaries to avoid processing links inside subs=false blocks var codeBlockRanges = GetCodeBlockRanges(markdown); - return LinkPattern().Replace(markdown, match => - { - // Check if this link is inside a code block with subs=false - if (IsInsideSubsDisabledCodeBlock(match.Index, codeBlockRanges)) - return match.Value; // Don't process links in subs=false code blocks + return LinkPattern().Replace( + markdown, + match => + { + // Check if this link is inside a code block with subs=false + if (IsInsideSubsDisabledCodeBlock(match.Index, codeBlockRanges)) + return match.Value; // Don't process links in subs=false code blocks - var linkText = match.Groups[1].Value; - var linkUrl = match.Groups[2].Value; + var linkText = match.Groups[1].Value; + var linkUrl = match.Groups[2].Value; - // Only preprocess external links to preserve internal link validation behavior - // Check if URL contains substitutions and looks like it might resolve to an external URL - if (linkUrl.Contains("{{") && (linkUrl.Contains("http") || linkText.Contains("{{"))) - { - // Apply substitutions to both link text and URL - var processedText = linkText.ReplaceSubstitutions(context); - var processedUrl = linkUrl.ReplaceSubstitutions(context); - return $"[{processedText}]({processedUrl})"; - } + // Only preprocess external links to preserve internal link validation behavior + // Check if URL contains substitutions and looks like it might resolve to an external URL + if (linkUrl.Contains("{{") && (linkUrl.Contains("http") || linkText.Contains("{{"))) + { + // Apply substitutions to both link text and URL + var processedText = linkText.ReplaceSubstitutions(context); + var processedUrl = linkUrl.ReplaceSubstitutions(context); + return $"[{processedText}]({processedUrl})"; + } - // Return original match for internal links - return match.Value; - }); + // Return original match for internal links + return match.Value; + } + ); } private static List<(int start, int end, bool subsDisabled)> GetCodeBlockRanges(string markdown) @@ -322,5 +365,4 @@ private static bool IsInsideSubsDisabledCodeBlock(int index, List<(int start, in return false; } - } diff --git a/src/Elastic.Markdown/Myst/ParserContext.cs b/src/Elastic.Markdown/Myst/ParserContext.cs index cd122d37c9..d515750f11 100644 --- a/src/Elastic.Markdown/Myst/ParserContext.cs +++ b/src/Elastic.Markdown/Myst/ParserContext.cs @@ -20,12 +20,10 @@ namespace Elastic.Markdown.Myst; public static class ParserContextExtensions { public static ParserContext GetContext(this InlineProcessor processor) => - processor.Context as ParserContext - ?? throw new InvalidOperationException($"Provided context is not a {nameof(ParserContext)}"); + processor.Context as ParserContext ?? throw new InvalidOperationException($"Provided context is not a {nameof(ParserContext)}"); public static ParserContext GetContext(this BlockProcessor processor) => - processor.Context as ParserContext - ?? throw new InvalidOperationException($"Provided context is not a {nameof(ParserContext)}"); + processor.Context as ParserContext ?? throw new InvalidOperationException($"Provided context is not a {nameof(ParserContext)}"); } public interface IParserResolvers diff --git a/src/Elastic.Markdown/Myst/Renderers/DefinitionTermAnchorRenderer.cs b/src/Elastic.Markdown/Myst/Renderers/DefinitionTermAnchorRenderer.cs index 3f3a0154cc..ca482b3264 100644 --- a/src/Elastic.Markdown/Myst/Renderers/DefinitionTermAnchorRenderer.cs +++ b/src/Elastic.Markdown/Myst/Renderers/DefinitionTermAnchorRenderer.cs @@ -69,8 +69,7 @@ protected override void Write(HtmlRenderer renderer, DefinitionList list) } var nextTerm = i + 1 < definitionItem.Count ? definitionItem[i + 1] : null; - var isSimpleParagraph = (nextTerm is null || nextTerm is DefinitionItem) && - countdd == 0 && node is ParagraphBlock; + var isSimpleParagraph = (nextTerm is null || nextTerm is DefinitionItem) && countdd == 0 && node is ParagraphBlock; var saveImplicit = renderer.ImplicitParagraph; if (isSimpleParagraph) { @@ -93,8 +92,7 @@ protected override void Write(HtmlRenderer renderer, DefinitionList list) _ = renderer.WriteLine(""); } - private static bool HasCodeInline(DefinitionTerm term) => - term.Inline?.Any(i => i is CodeInline) ?? false; + private static bool HasCodeInline(DefinitionTerm term) => term.Inline?.Any(i => i is CodeInline) ?? false; /// /// Extracts a clean anchor id from a parameter term: diff --git a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmApplicabilityHelper.cs b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmApplicabilityHelper.cs index e37307c5b1..84f6c9715c 100644 --- a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmApplicabilityHelper.cs +++ b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmApplicabilityHelper.cs @@ -31,14 +31,18 @@ public static string RenderStackRowForLlm(ApplicableTo? appliesTo, VersionsConfi RenderPlacementForLlm(appliesTo, versionsConfig, ApplicabilityBadgePlacement.StackRow, useInlineTag); /// Deployment and serverless line for settings-style split layout. - public static string RenderSupportedOnRowForLlm(ApplicableTo? appliesTo, VersionsConfiguration versionsConfig, bool useInlineTag = true) => - RenderPlacementForLlm(appliesTo, versionsConfig, ApplicabilityBadgePlacement.SupportedOnRow, useInlineTag); + public static string RenderSupportedOnRowForLlm( + ApplicableTo? appliesTo, + VersionsConfiguration versionsConfig, + bool useInlineTag = true + ) => RenderPlacementForLlm(appliesTo, versionsConfig, ApplicabilityBadgePlacement.SupportedOnRow, useInlineTag); private static string RenderPlacementForLlm( ApplicableTo? appliesTo, VersionsConfiguration versionsConfig, ApplicabilityBadgePlacement placement, - bool useInlineTag) + bool useInlineTag + ) { if (appliesTo is null || appliesTo == ApplicableTo.All) return string.Empty; @@ -122,6 +126,5 @@ private static string GetAvailabilityText(ApplicabilityItem item) /// /// Converts display name from HTML entities to plain text (e.g., "Elastic&nbsp;Stack" -> "Elastic Stack") /// - private static string GetPlainDisplayName(string displayName) => - displayName.Replace(" ", " "); + private static string GetPlainDisplayName(string displayName) => displayName.Replace(" ", " "); } diff --git a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs index 74265537a6..b4a4763a67 100644 --- a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs +++ b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs @@ -32,10 +32,15 @@ public static class LlmRenderingHelpers { public static void RenderBlockWithIndentation(LlmMarkdownRenderer renderer, MarkdownObject block, string indentation = " ") { - var content = DocumentationObjectPoolProvider.UseLlmMarkdownRenderer(renderer.BuildContext, renderer.LinkUrlRewriter, block, static (tmpRenderer, obj) => - { - _ = tmpRenderer.Render(obj); - }); + var content = DocumentationObjectPoolProvider.UseLlmMarkdownRenderer( + renderer.BuildContext, + renderer.LinkUrlRewriter, + block, + static (tmpRenderer, obj) => + { + _ = tmpRenderer.Render(obj); + } + ); if (string.IsNullOrEmpty(content)) return; @@ -59,8 +64,10 @@ public static void RenderBlockWithIndentation(LlmMarkdownRenderer renderer, Mark // Convert localhost URLs to canonical URLs for LLM consumption if (!string.IsNullOrEmpty(url) && url.StartsWith("http://localhost", StringComparison.OrdinalIgnoreCase)) { - if (Uri.TryCreate(url, UriKind.Absolute, out var localhostUri) && - localhostUri.AbsolutePath.StartsWith("/docs/", StringComparison.Ordinal)) + if ( + Uri.TryCreate(url, UriKind.Absolute, out var localhostUri) && + localhostUri.AbsolutePath.StartsWith("/docs/", StringComparison.Ordinal) + ) { // Replace localhost with canonical base URL var canonicalUrl = new Uri(renderer.BuildContext.CanonicalBaseUrl, localhostUri.AbsolutePath); @@ -80,7 +87,8 @@ public static void RenderBlockWithIndentation(LlmMarkdownRenderer renderer, Mark string.IsNullOrEmpty(url) || baseUri == null || Uri.IsWellFormedUriString(url, UriKind.Absolute) - || !Uri.IsWellFormedUriString(url, UriKind.Relative)) + || !Uri.IsWellFormedUriString(url, UriKind.Relative) + ) return url; try { @@ -243,7 +251,8 @@ private static void WriteAppliesToDirective(LlmMarkdownRenderer renderer, Applie var appliesText = LlmApplicabilityHelper.RenderForLlm( directive.AppliesTo, renderer.BuildContext.VersionsConfiguration, - useInlineTag: false); + useInlineTag: false + ); if (string.IsNullOrEmpty(appliesText)) return; @@ -322,9 +331,7 @@ protected override void Write(LlmMarkdownRenderer renderer, ListBlock listBlock) renderer.EnsureBlockSpacing(); var isOrdered = listBlock.IsOrdered; - var itemIndex = listBlock.IsOrdered && int.TryParse(listBlock.DefaultOrderedStart, out var startIndex) - ? startIndex - : 1; + var itemIndex = listBlock.IsOrdered && int.TryParse(listBlock.DefaultOrderedStart, out var startIndex) ? startIndex : 1; var items = listBlock.Cast().ToArray(); foreach (var item in items) @@ -347,8 +354,7 @@ protected override void Write(LlmMarkdownRenderer renderer, ListBlock listBlock) } } - private static string GetContinuationIndent(string baseIndent, bool isOrdered) => - baseIndent + new string(' ', isOrdered ? 3 : 2); + private static string GetContinuationIndent(string baseIndent, bool isOrdered) => baseIndent + new string(' ', isOrdered ? 3 : 2); private static void RenderBlockWithIndentation(LlmMarkdownRenderer renderer, Block block, string baseIndent, bool isOrdered) { @@ -360,10 +366,15 @@ private static void RenderBlockWithIndentation(LlmMarkdownRenderer renderer, Blo } // Render other blocks in separate context and re-indent each line - var blockOutput = DocumentationObjectPoolProvider.UseLlmMarkdownRenderer(renderer.BuildContext, renderer.LinkUrlRewriter, block, static (tmpRenderer, obj) => - { - _ = tmpRenderer.Render(obj); - }); + var blockOutput = DocumentationObjectPoolProvider.UseLlmMarkdownRenderer( + renderer.BuildContext, + renderer.LinkUrlRewriter, + block, + static (tmpRenderer, obj) => + { + _ = tmpRenderer.Render(obj); + } + ); var continuationIndent = GetContinuationIndent(baseIndent, isOrdered); var lines = blockOutput.Split('\n'); @@ -451,9 +462,7 @@ protected override void Write(LlmMarkdownRenderer renderer, Table table) // Convert Table to list of string arrays for shared rendering var rows = table.Cast() - .Select(row => row.Cast() - .Select(cell => RenderTableCellContent(renderer, cell)) - .ToArray() as IReadOnlyList) + .Select(row => row.Cast().Select(cell => RenderTableCellContent(renderer, cell)).ToArray() as IReadOnlyList) .ToList(); LlmRenderingHelpers.RenderMarkdownTable(renderer, rows); @@ -472,7 +481,8 @@ private static string RenderTableCellContent(LlmMarkdownRenderer renderer, Table // Render the cell's child blocks (e.g., ParagraphBlock) which properly // handles the inline hierarchy without duplicating nested inline content tmpRenderer.WriteChildren(c); - }).Trim(); + } + ).Trim(); } public class LlmDirectiveRenderer : MarkdownObjectRenderer @@ -531,8 +541,7 @@ protected override void Write(LlmMarkdownRenderer renderer, DirectiveBlock obj) switch (obj) { - case AdmonitionBlock when obj.Directive - is "note" or "tip" or "warning" or "important": + case AdmonitionBlock when obj.Directive is "note" or "tip" or "warning" or "important": // skip for these directives // otherwise it will render as break; @@ -547,7 +556,8 @@ protected override void Write(LlmMarkdownRenderer renderer, DirectiveBlock obj) var appliesText = LlmApplicabilityHelper.RenderForLlm( appliesBlock.AppliesTo, renderer.BuildContext.VersionsConfiguration, - useInlineTag: false); + useInlineTag: false + ); if (!string.IsNullOrEmpty(appliesText)) renderer.Writer.Write($" applies-to=\"{appliesText}\""); break; @@ -622,9 +632,7 @@ private static void WriteWhatsNewItem(LlmMarkdownRenderer renderer, WhatsNewItem return; renderer.EnsureLine(); - var title = string.IsNullOrEmpty(item.Link) - ? item.Title - : $"[{item.Title}]({HubLinkForLlm(renderer, item.Link)})"; + var title = string.IsNullOrEmpty(item.Link) ? item.Title : $"[{item.Title}]({HubLinkForLlm(renderer, item.Link)})"; var meta = new List(2); if (!string.IsNullOrEmpty(item.Date)) @@ -664,9 +672,7 @@ private static void WriteGetStartedBlock(LlmMarkdownRenderer renderer, GetStarte private static void WriteGetStartedStep(LlmMarkdownRenderer renderer, GetStartedStep step, int number) { renderer.EnsureLine(); - var title = string.IsNullOrEmpty(step.Link) - ? step.Title - : $"[{step.Title}]({HubLinkForLlm(renderer, step.Link)})"; + var title = string.IsNullOrEmpty(step.Link) ? step.Title : $"[{step.Title}]({HubLinkForLlm(renderer, step.Link)})"; renderer.WriteLine($"{number}. {title}"); if (!string.IsNullOrEmpty(step.Description)) @@ -723,9 +729,7 @@ private static void WriteLinkCardBlock(LlmMarkdownRenderer renderer, LinkCardBlo if (!string.IsNullOrEmpty(data.Title)) { - var heading = string.IsNullOrEmpty(data.Link) - ? data.Title - : $"[{data.Title}]({HubLinkForLlm(renderer, data.Link)})"; + var heading = string.IsNullOrEmpty(data.Link) ? data.Title : $"[{data.Title}]({HubLinkForLlm(renderer, data.Link)})"; renderer.WriteLine($"#### {heading}"); renderer.EnsureLine(); } @@ -805,8 +809,15 @@ private void WriteIncludeBlock(LlmMarkdownRenderer renderer, IncludeBlock block) try { var parentPath = block.Context.MarkdownParentPath ?? block.Context.MarkdownSourcePath; - var document = MarkdownParser.ParseSnippetAsync(block.Build, block.Context, snippet, parentPath, block.Context.YamlFrontMatter, Cancel.None, block.Line) - .GetAwaiter().GetResult(); + var document = MarkdownParser.ParseSnippetAsync( + block.Build, + block.Context, + snippet, + parentPath, + block.Context.YamlFrontMatter, + Cancel.None, + block.Line + ).GetAwaiter().GetResult(); _ = renderer.Render(document); } catch (Exception ex) @@ -823,9 +834,12 @@ private void WriteSettingsBlock(LlmMarkdownRenderer renderer, SettingsBlock bloc if (!block.Found || block.IncludePath is null) { var path = block.IncludePath ?? "(no path specified)"; - renderer.BuildContext.Collector.EmitError( - block.IncludePath ?? string.Empty, - $"Settings directive error: Could not resolve path '{path}'. Ensure the file exists and the path is correct."); + renderer.BuildContext + .Collector + .EmitError( + block.IncludePath ?? string.Empty, + $"Settings directive error: Could not resolve path '{path}'. Ensure the file exists and the path is correct." + ); return; } @@ -834,9 +848,12 @@ private void WriteSettingsBlock(LlmMarkdownRenderer renderer, SettingsBlock bloc // Check if file exists before attempting to read if (!file.Exists) { - renderer.BuildContext.Collector.EmitError( - block.IncludePath, - $"Settings file not found: '{block.IncludePath}' does not exist. Check that the file path is correct and the file has been committed to the repository."); + renderer.BuildContext + .Collector + .EmitError( + block.IncludePath, + $"Settings file not found: '{block.IncludePath}' does not exist. Check that the file path is correct and the file has been committed to the repository." + ); return; } @@ -845,41 +862,54 @@ private void WriteSettingsBlock(LlmMarkdownRenderer renderer, SettingsBlock bloc { var yaml = file.FileSystem.File.ReadAllText(file.FullName); SettingsBlock.CollectSubstitutionUsageFromYaml(yaml, block.Context.Build); - settings = SettingsBlock.PrepareSettingsForRendering( - YamlSerialization.Deserialize(yaml, block.Context.Build.ProductsConfiguration), - block.Context - ); + settings = + SettingsBlock.PrepareSettingsForRendering( + YamlSerialization.Deserialize(yaml, block.Context.Build.ProductsConfiguration), + block.Context + ); } catch (FileNotFoundException e) { - renderer.BuildContext.Collector.EmitError( - block.IncludePath, - $"Settings file not found: Unable to read '{block.IncludePath}'. The file may have been moved or deleted.", - e); + renderer.BuildContext + .Collector + .EmitError( + block.IncludePath, + $"Settings file not found: Unable to read '{block.IncludePath}'. The file may have been moved or deleted.", + e + ); return; } catch (DirectoryNotFoundException e) { - renderer.BuildContext.Collector.EmitError( - block.IncludePath, - $"Settings directory not found: The directory containing '{block.IncludePath}' does not exist. Check that the path is correct.", - e); + renderer.BuildContext + .Collector + .EmitError( + block.IncludePath, + $"Settings directory not found: The directory containing '{block.IncludePath}' does not exist. Check that the path is correct.", + e + ); return; } catch (YamlException e) { - renderer.BuildContext.Collector.EmitError( - block.IncludePath, - $"Invalid YAML in settings file: '{block.IncludePath}' contains invalid YAML syntax. Please check the file format matches the expected settings structure (groups, settings, etc.).", - e.InnerException ?? e); + renderer.BuildContext + .Collector + .EmitError( + block.IncludePath, + $"Invalid YAML in settings file: '{block.IncludePath}' contains invalid YAML syntax. Please check the file format matches the expected settings structure (groups, settings, etc.).", + e.InnerException ?? e + ); return; } catch (Exception e) { - renderer.BuildContext.Collector.EmitError( - block.IncludePath, - $"Failed to process settings file: Unable to parse '{block.IncludePath}'. Error: {e.Message}", - e); + renderer.BuildContext + .Collector + .EmitError( + block.IncludePath, + $"Failed to process settings file: Unable to parse '{block.IncludePath}'. Error: {e.Message}", + e + ); return; } @@ -916,12 +946,21 @@ private static void RenderSetting( Setting setting, string? parentName, Elastic.Documentation.AppliesTo.ApplicableTo? inheritedAppliesTo, - string? product) + string? product + ) { var displayName = SettingsViewModel.ComposeSettingName(parentName, setting.Name); var appliesTo = setting.ResolveAppliesTo(inheritedAppliesTo); - var stackAvailability = LlmApplicabilityHelper.RenderStackRowForLlm(appliesTo, renderer.BuildContext.VersionsConfiguration, useInlineTag: false); - var supportedOn = LlmApplicabilityHelper.RenderSupportedOnRowForLlm(appliesTo, renderer.BuildContext.VersionsConfiguration, useInlineTag: false); + var stackAvailability = LlmApplicabilityHelper.RenderStackRowForLlm( + appliesTo, + renderer.BuildContext.VersionsConfiguration, + useInlineTag: false + ); + var supportedOn = LlmApplicabilityHelper.RenderSupportedOnRowForLlm( + appliesTo, + renderer.BuildContext.VersionsConfiguration, + useInlineTag: false + ); var showSupportedOn = appliesTo is not null && appliesTo != Documentation.AppliesTo.ApplicableTo.All; renderer.WriteLine(" "); @@ -971,7 +1010,8 @@ private static void RenderAdmonitionSnippet( string directive, string? content, string? title = null, - string? product = null) + string? product = null + ) { if (string.IsNullOrWhiteSpace(content)) return; @@ -981,7 +1021,12 @@ private static void RenderAdmonitionSnippet( RenderSettingsMarkdownSnippet(renderer, block, snippet, product); } - private static void RenderSettingsMarkdownSnippet(LlmMarkdownRenderer renderer, SettingsBlock block, string? content, string? product = null) + private static void RenderSettingsMarkdownSnippet( + LlmMarkdownRenderer renderer, + SettingsBlock block, + string? content, + string? product = null + ) { if (string.IsNullOrWhiteSpace(content)) return; @@ -996,7 +1041,8 @@ private static void RenderSettingsMarkdownSnippet(LlmMarkdownRenderer renderer, block.Build.ReadFileSystem.FileInfo.New(block.IncludePath), block.Context.YamlFrontMatter, block.IncludeFrom, - MarkdownParser.Pipeline); + MarkdownParser.Pipeline + ); _ = renderer.Render(document); renderer.EnsureBlockSpacing(); } @@ -1031,9 +1077,7 @@ private static void WriteCsvIncludeBlock(LlmMarkdownRenderer renderer, CsvInclud { if (!block.Found || string.IsNullOrEmpty(block.CsvFilePath)) { - renderer.BuildContext.Collector.EmitError( - block.CsvFilePath ?? string.Empty, - "CSV file not found or invalid path"); + renderer.BuildContext.Collector.EmitError(block.CsvFilePath ?? string.Empty, "CSV file not found or invalid path"); return; } @@ -1093,7 +1137,9 @@ private static void WriteStorybookBlock(LlmMarkdownRenderer renderer, StorybookB renderer.Writer.Write($" storybook=\"{WebUtility.HtmlEncode(block.Storybook)}\""); if (!string.IsNullOrWhiteSpace(block.StoryId)) renderer.Writer.Write($" story-id=\"{WebUtility.HtmlEncode(block.StoryId)}\""); - renderer.Writer.Write($" src=\"{WebUtility.HtmlEncode(LlmRenderingHelpers.MakeAbsoluteUrl(renderer, block.StoryUrl) ?? block.StoryUrl)}\""); + renderer.Writer.Write( + $" src=\"{WebUtility.HtmlEncode(LlmRenderingHelpers.MakeAbsoluteUrl(renderer, block.StoryUrl) ?? block.StoryUrl)}\"" + ); renderer.Writer.WriteLine(">"); if (block.Count > 0) WriteChildrenWithIndentation(renderer, block, " "); @@ -1104,18 +1150,23 @@ private static void WriteStorybookBlock(LlmMarkdownRenderer renderer, StorybookB private static void WriteChildrenWithIndentation(LlmMarkdownRenderer renderer, Block container, string indent) { // Capture output and manually add indentation - var content = DocumentationObjectPoolProvider.UseLlmMarkdownRenderer(renderer.BuildContext, renderer.LinkUrlRewriter, container, static (tmpRenderer, obj) => - { - switch (obj) + var content = DocumentationObjectPoolProvider.UseLlmMarkdownRenderer( + renderer.BuildContext, + renderer.LinkUrlRewriter, + container, + static (tmpRenderer, obj) => { - case ContainerBlock containerBlock: - tmpRenderer.WriteChildren(containerBlock); - break; - case LeafBlock leafBlock: - tmpRenderer.WriteLeafInline(leafBlock); - break; + switch (obj) + { + case ContainerBlock containerBlock: + tmpRenderer.WriteChildren(containerBlock); + break; + case LeafBlock leafBlock: + tmpRenderer.WriteLeafInline(leafBlock); + break; + } } - }); + ); if (string.IsNullOrEmpty(content)) return; @@ -1157,10 +1208,15 @@ protected override void Write(LlmMarkdownRenderer renderer, DefinitionItem obj) private static string GetPlainTextFromLeafBlock(LlmMarkdownRenderer renderer, LeafBlock leafBlock) { - var markdownText = DocumentationObjectPoolProvider.UseLlmMarkdownRenderer(renderer.BuildContext, renderer.LinkUrlRewriter, leafBlock, static (tmpRenderer, obj) => - { - tmpRenderer.WriteLeafInline(obj); - }); + var markdownText = DocumentationObjectPoolProvider.UseLlmMarkdownRenderer( + renderer.BuildContext, + renderer.LinkUrlRewriter, + leafBlock, + static (tmpRenderer, obj) => + { + tmpRenderer.WriteLeafInline(obj); + } + ); return markdownText.StripMarkdown(); } } diff --git a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmInlineRenderers.cs b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmInlineRenderers.cs index 64c7a785dc..70ae2b0a98 100644 --- a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmInlineRenderers.cs +++ b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmInlineRenderers.cs @@ -46,7 +46,6 @@ protected override void Write(LlmMarkdownRenderer renderer, LinkInline obj) } renderer.Writer.Write(")"); } - } public class LlmEmphasisInlineRenderer : MarkdownObjectRenderer @@ -62,8 +61,8 @@ protected override void Write(LlmMarkdownRenderer renderer, EmphasisInline obj) public class LlmSubstitutionLeafRenderer : MarkdownObjectRenderer { - protected override void Write(LlmMarkdownRenderer renderer, SubstitutionLeaf obj) - => renderer.Writer.Write(obj.Found ? obj.Replacement : obj.Content); + protected override void Write(LlmMarkdownRenderer renderer, SubstitutionLeaf obj) => + renderer.Writer.Write(obj.Found ? obj.Replacement : obj.Content); } public class LlmCodeInlineRenderer : MarkdownObjectRenderer @@ -108,7 +107,8 @@ protected override void Write(LlmMarkdownRenderer renderer, RoleLeaf obj) { var text = LlmApplicabilityHelper.RenderForLlm( appliesToRole.AppliesTo, - appliesToRole.BuildContext.VersionsConfiguration); + appliesToRole.BuildContext.VersionsConfiguration + ); if (!string.IsNullOrEmpty(text)) renderer.Writer.Write(text); break; diff --git a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmMarkdownRenderer.cs b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmMarkdownRenderer.cs index bf6a9700f7..f588ca33e1 100644 --- a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmMarkdownRenderer.cs +++ b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmMarkdownRenderer.cs @@ -99,6 +99,4 @@ public LlmMarkdownRenderer(TextWriter writer) : base(writer) ObjectRenderers.Add(new LlmTableRenderer()); ObjectRenderers.Add(new LlmListRenderer()); } - - } diff --git a/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs b/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs index 49c8f4dfd3..3964e31fc0 100644 --- a/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs +++ b/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs @@ -77,7 +77,8 @@ protected override void Write(PlainTextRenderer renderer, EnhancedCodeBlock obj) var appliesText = LlmApplicabilityHelper.RenderForLlm( appliesTo.AppliesTo, renderer.BuildContext.VersionsConfiguration, - useInlineTag: false); + useInlineTag: false + ); if (!string.IsNullOrEmpty(appliesText)) { renderer.EnsureBlockSpacing(); @@ -192,9 +193,7 @@ protected override void Write(PlainTextRenderer renderer, Table table) // Get headers from first row if (table.Count > 0 && table[0] is TableRow headerRow) { - headers = headerRow.Cast() - .Select(cell => RenderCellContent(renderer, cell)) - .ToArray(); + headers = headerRow.Cast().Select(cell => RenderCellContent(renderer, cell)).ToArray(); } // Render each data row as header: value pairs @@ -244,15 +243,12 @@ protected override void Write(PlainTextRenderer renderer, DirectiveBlock obj) renderer.WriteLine(imageBlock.Alt); } return; - case IncludeBlock includeBlock: WriteIncludeBlock(renderer, includeBlock); return; - case SettingsBlock settingsBlock: WriteSettingsBlock(renderer, settingsBlock); return; - case MathBlock mathBlock: // Output math content as-is for search if (!string.IsNullOrEmpty(mathBlock.Content)) @@ -261,19 +257,15 @@ protected override void Write(PlainTextRenderer renderer, DirectiveBlock obj) renderer.WriteLine(mathBlock.Content); } return; - case TabSetBlock tabSetBlock: WriteTabSetBlock(renderer, tabSetBlock); return; - case TabItemBlock tabItemBlock: WriteTabItemBlock(renderer, tabItemBlock); return; - case CsvIncludeBlock csvIncludeBlock: WriteCsvIncludeBlock(renderer, csvIncludeBlock); return; - // A hub page exists to answer generic " docs" queries. Index the identity // only. Section and card titles would let the hub compete with the pages it links to // on specific queries, which is the opposite of what it is for. @@ -285,7 +277,6 @@ protected override void Write(PlainTextRenderer renderer, DirectiveBlock obj) renderer.WriteLine(heroBlock.Description); renderer.EnsureLine(); return; - // Deliberately contributes nothing. Section, card, and link titles are the tokens // that would let a hub outrank the pages it links to on a specific query. case ExploreBlock: @@ -294,7 +285,6 @@ protected override void Write(PlainTextRenderer renderer, DirectiveBlock obj) case GetStartedBlock: case WhatsNewBlock: return; - case AgentSkillBlock agentSkillBlock: renderer.EnsureBlockSpacing(); renderer.WriteLine("Agent skill available"); @@ -379,15 +369,17 @@ private static void WriteIncludeBlock(PlainTextRenderer renderer, IncludeBlock b { var parentPath = block.Context.MarkdownParentPath ?? block.Context.MarkdownSourcePath; var document = MarkdownParser.ParseSnippetAsync( - block.Build, block.Context, snippet, parentPath, - block.Context.YamlFrontMatter, Cancel.None, block.Line + block.Build, + block.Context, + snippet, + parentPath, + block.Context.YamlFrontMatter, + Cancel.None, + block.Line ).GetAwaiter().GetResult(); _ = renderer.Render(document); } - catch (Exception ex) when (ex is not OutOfMemoryException - and not ThreadAbortException - and not ThreadInterruptedException - and not StackOverflowException) + catch (Exception ex) when (ex is not OutOfMemoryException and not ThreadAbortException and not ThreadInterruptedException and not StackOverflowException) { // Skip on error } @@ -408,10 +400,11 @@ private static void WriteSettingsBlock(PlainTextRenderer renderer, SettingsBlock { var yaml = file.FileSystem.File.ReadAllText(file.FullName); SettingsBlock.CollectSubstitutionUsageFromYaml(yaml, block.Context.Build); - settings = SettingsBlock.PrepareSettingsForRendering( - YamlSerialization.Deserialize(yaml, block.Context.Build.ProductsConfiguration), - block.Context - ); + settings = + SettingsBlock.PrepareSettingsForRendering( + YamlSerialization.Deserialize(yaml, block.Context.Build.ProductsConfiguration), + block.Context + ); } catch { @@ -438,7 +431,13 @@ private static void WriteSettingsBlock(PlainTextRenderer renderer, SettingsBlock renderer.EnsureLine(); } - private static void WriteSettingPlainText(PlainTextRenderer renderer, SettingsBlock block, Setting setting, string? parentName, string? product) + private static void WriteSettingPlainText( + PlainTextRenderer renderer, + SettingsBlock block, + Setting setting, + string? parentName, + string? product + ) { var displayName = SettingsViewModel.ComposeSettingName(parentName, setting.Name); renderer.EnsureLine(); @@ -482,7 +481,13 @@ private static void WriteSettingPlainText(PlainTextRenderer renderer, SettingsBl renderer.EnsureBlockSpacing(); } - private static void WriteSettingsMarkdownSnippet(PlainTextRenderer renderer, SettingsBlock block, string? markdown, string? label = null, string? product = null) + private static void WriteSettingsMarkdownSnippet( + PlainTextRenderer renderer, + SettingsBlock block, + string? markdown, + string? label = null, + string? product = null + ) { if (string.IsNullOrWhiteSpace(markdown)) return; @@ -502,7 +507,8 @@ private static void WriteSettingsMarkdownSnippet(PlainTextRenderer renderer, Set settingsSourceFile, block.Context.YamlFrontMatter, block.IncludeFrom, - MarkdownParser.Pipeline); + MarkdownParser.Pipeline + ); _ = renderer.Render(document); renderer.EnsureBlockSpacing(); } @@ -522,9 +528,7 @@ private static void WriteCsvIncludeBlock(PlainTextRenderer renderer, CsvIncludeB } // Read CSV data - var csvRows = CsvReader.ReadCsvFile(block.CsvFilePath, block.Separator, block.Build.ReadFileSystem) - .Take(block.MaxRows) - .ToList(); + var csvRows = CsvReader.ReadCsvFile(block.CsvFilePath, block.Separator, block.Build.ReadFileSystem).Take(block.MaxRows).ToList(); if (csvRows.Count == 0) return; @@ -554,7 +558,6 @@ private static void WriteCsvIncludeBlock(PlainTextRenderer renderer, CsvIncludeB renderer.EnsureLine(); } - } /// diff --git a/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextInlineRenderers.cs b/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextInlineRenderers.cs index 030adbb1d4..28b5f210b6 100644 --- a/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextInlineRenderers.cs +++ b/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextInlineRenderers.cs @@ -37,8 +37,8 @@ protected override void Write(PlainTextRenderer renderer, EmphasisInline obj) => /// public class PlainTextSubstitutionRenderer : MarkdownObjectRenderer { - protected override void Write(PlainTextRenderer renderer, SubstitutionLeaf obj) - => renderer.Write(obj.Found ? obj.Replacement : obj.Content); + protected override void Write(PlainTextRenderer renderer, SubstitutionLeaf obj) => + renderer.Write(obj.Found ? obj.Replacement : obj.Content); } /// @@ -46,8 +46,7 @@ protected override void Write(PlainTextRenderer renderer, SubstitutionLeaf obj) /// public class PlainTextCodeInlineRenderer : MarkdownObjectRenderer { - protected override void Write(PlainTextRenderer renderer, CodeInline obj) - => renderer.Write(obj.Content); + protected override void Write(PlainTextRenderer renderer, CodeInline obj) => renderer.Write(obj.Content); } /// @@ -55,8 +54,7 @@ protected override void Write(PlainTextRenderer renderer, CodeInline obj) /// public class PlainTextLiteralRenderer : MarkdownObjectRenderer { - protected override void Write(PlainTextRenderer renderer, LiteralInline obj) - => renderer.Write(obj.Content.ToString()); + protected override void Write(PlainTextRenderer renderer, LiteralInline obj) => renderer.Write(obj.Content.ToString()); } /// @@ -93,7 +91,8 @@ protected override void Write(PlainTextRenderer renderer, RoleLeaf obj) var appliesText = LlmApplicabilityHelper.RenderForLlm( appliesToRole.AppliesTo, appliesToRole.BuildContext.VersionsConfiguration, - useInlineTag: false); + useInlineTag: false + ); if (!string.IsNullOrEmpty(appliesText)) renderer.Write($"({appliesText})"); break; diff --git a/src/Elastic.Markdown/Myst/Renderers/SectionedHeadingRenderer.cs b/src/Elastic.Markdown/Myst/Renderers/SectionedHeadingRenderer.cs index 41129406dc..17fbead082 100644 --- a/src/Elastic.Markdown/Myst/Renderers/SectionedHeadingRenderer.cs +++ b/src/Elastic.Markdown/Myst/Renderers/SectionedHeadingRenderer.cs @@ -12,23 +12,13 @@ namespace Elastic.Markdown.Myst.Renderers; public class SectionedHeadingRenderer : HtmlObjectRenderer { - private static readonly string[] HeadingTexts = - [ - "h1", - "h2", - "h3", - "h4", - "h5", - "h6" - ]; + private static readonly string[] HeadingTexts = ["h1", "h2", "h3", "h4", "h5", "h6"]; protected override void Write(HtmlRenderer renderer, HeadingBlock obj) { var index = obj.Level - 1; var headings = HeadingTexts; - var headingText = ((uint)index < (uint)headings.Length) - ? headings[index] - : $"h{obj.Level}"; + var headingText = ((uint)index < (uint)headings.Length) ? headings[index] : $"h{obj.Level}"; var header = obj.GetData("header") as string; var anchor = obj.GetData("anchor") as string; @@ -39,20 +29,21 @@ protected override void Write(HtmlRenderer renderer, HeadingBlock obj) var slug = slugTarget.Slugify(); - _ = renderer.Write(@"
") - .Write('<') - .Write(headingText) - .WriteAttributes(obj) - .Write('>') - .Write($"""""") - .WriteLeafInline(obj) - .Write("") - .Write("') - .Write("
") - .EnsureLine(); + _ = + renderer.Write(@"
") + .Write('<') + .Write(headingText) + .WriteAttributes(obj) + .Write('>') + .Write($"""""") + .WriteLeafInline(obj) + .Write("") + .Write("') + .Write("
") + .EnsureLine(); } } diff --git a/src/Elastic.Markdown/Myst/Roles/AppliesTo/AppliesToRole.cs b/src/Elastic.Markdown/Myst/Roles/AppliesTo/AppliesToRole.cs index a2803228be..34252c832d 100644 --- a/src/Elastic.Markdown/Myst/Roles/AppliesTo/AppliesToRole.cs +++ b/src/Elastic.Markdown/Myst/Roles/AppliesTo/AppliesToRole.cs @@ -63,9 +63,7 @@ protected override AppliesToRole CreateRole(string role, string content, InlineP { content = SemVersion.TryParse(content, out _) ? $"product: preview {content}" - : SemVersion.TryParse(content + ".0", out var version) - ? $"product: preview {version}" - : "product: preview"; + : SemVersion.TryParse(content + ".0", out var version) ? $"product: preview {version}" : "product: preview"; return new AppliesToRole(role, content, parserContext); } diff --git a/src/Elastic.Markdown/Myst/Roles/Kbd/Kbd.cs b/src/Elastic.Markdown/Myst/Roles/Kbd/Kbd.cs index 865c6f32b8..c793d0a7dc 100644 --- a/src/Elastic.Markdown/Myst/Roles/Kbd/Kbd.cs +++ b/src/Elastic.Markdown/Myst/Roles/Kbd/Kbd.cs @@ -14,12 +14,7 @@ public class KeyboardShortcut(IReadOnlyList keys) { private IReadOnlyList Keys { get; } = keys; - public static KeyboardShortcut Unknown { get; } = new([ - new CharacterKeyNode - { - Key = '?' - } - ]); + public static KeyboardShortcut Unknown { get; } = new([new CharacterKeyNode { Key = '?' }]); public static KeyboardShortcut Parse(string input) { @@ -59,24 +54,30 @@ private static IKeyNode ParseSingleKey(string key) public static string Render(KeyboardShortcut shortcut) { var viewModels = shortcut.Keys.Select(ToViewModel); - var kbdElements = viewModels.Select(viewModel => viewModel switch - { - SingleKeyboardKeyViewModel singleKeyboardKeyViewModel => Render(singleKeyboardKeyViewModel), - AlternateKeyboardKeyViewModel alternateKeyboardKeyViewModel => Render(alternateKeyboardKeyViewModel), - _ => throw new ArgumentException($"Unsupported key: {viewModel}", nameof(viewModel)) - }); + var kbdElements = viewModels.Select( + viewModel => + viewModel switch + { + SingleKeyboardKeyViewModel singleKeyboardKeyViewModel => Render(singleKeyboardKeyViewModel), + AlternateKeyboardKeyViewModel alternateKeyboardKeyViewModel => Render(alternateKeyboardKeyViewModel), + _ => throw new ArgumentException($"Unsupported key: {viewModel}", nameof(viewModel)) + } + ); return string.Join(" + ", kbdElements); } public static string RenderLlm(KeyboardShortcut shortcut) { var viewModels = shortcut.Keys.Select(ToViewModel); - var kbdElements = viewModels.Select(viewModel => viewModel switch - { - SingleKeyboardKeyViewModel singleKeyboardKeyViewModel => RenderLlm(singleKeyboardKeyViewModel), - AlternateKeyboardKeyViewModel alternateKeyboardKeyViewModel => RenderLlm(alternateKeyboardKeyViewModel), - _ => throw new ArgumentException($"Unsupported key: {viewModel}", nameof(viewModel)) - }); + var kbdElements = viewModels.Select( + viewModel => + viewModel switch + { + SingleKeyboardKeyViewModel singleKeyboardKeyViewModel => RenderLlm(singleKeyboardKeyViewModel), + AlternateKeyboardKeyViewModel alternateKeyboardKeyViewModel => RenderLlm(alternateKeyboardKeyViewModel), + _ => throw new ArgumentException($"Unsupported key: {viewModel}", nameof(viewModel)) + } + ); return string.Join(" + ", kbdElements); } @@ -86,13 +87,16 @@ public static string RenderLlm(KeyboardShortcut shortcut) public static string RenderPlainText(KeyboardShortcut shortcut) { var viewModels = shortcut.Keys.Select(ToViewModel); - var keyElements = viewModels.Select(viewModel => viewModel switch - { - SingleKeyboardKeyViewModel singleKeyboardKeyViewModel => singleKeyboardKeyViewModel.DisplayText, - AlternateKeyboardKeyViewModel alternateKeyboardKeyViewModel => - $"{alternateKeyboardKeyViewModel.Primary.DisplayText} / {alternateKeyboardKeyViewModel.Alternate.DisplayText}", - _ => throw new ArgumentException($"Unsupported key: {viewModel}", nameof(viewModel)) - }); + var keyElements = viewModels.Select( + viewModel => + viewModel switch + { + SingleKeyboardKeyViewModel singleKeyboardKeyViewModel => singleKeyboardKeyViewModel.DisplayText, + AlternateKeyboardKeyViewModel alternateKeyboardKeyViewModel => + $"{alternateKeyboardKeyViewModel.Primary.DisplayText} / {alternateKeyboardKeyViewModel.Alternate.DisplayText}", + _ => throw new ArgumentException($"Unsupported key: {viewModel}", nameof(viewModel)) + } + ); return string.Join(" + ", keyElements); } @@ -121,7 +125,11 @@ private static string Render(AlternateKeyboardKeyViewModel alternateKeyboardKeyV var sb = new StringBuilder(); _ = sb.Append("'); if (alternateKeyboardKeyViewModel.Primary.UnicodeIcon is not null) @@ -151,14 +159,13 @@ private static string Render(SingleKeyboardKeyViewModel singleKeyboardKeyViewMod return sb.ToString(); } - private static IKeyboardViewModel ToViewModel(IKeyNode keyNode) => - keyNode switch - { - AlternateKeyNode alternateKeyNode => ToViewModel(alternateKeyNode), - CharacterKeyNode characterKeyNode => ToViewModel(characterKeyNode), - NamedKeyNode namedKeyNode => ToViewModel(namedKeyNode), - _ => throw new ArgumentException($"Unknown key: {keyNode}") - }; + private static IKeyboardViewModel ToViewModel(IKeyNode keyNode) => keyNode switch + { + AlternateKeyNode alternateKeyNode => ToViewModel(alternateKeyNode), + CharacterKeyNode characterKeyNode => ToViewModel(characterKeyNode), + NamedKeyNode namedKeyNode => ToViewModel(namedKeyNode), + _ => throw new ArgumentException($"Unknown key: {keyNode}") + }; private static AlternateKeyboardKeyViewModel ToViewModel(AlternateKeyNode keyNode) => new() @@ -177,258 +184,149 @@ private static AlternateKeyboardKeyViewModel ToViewModel(AlternateKeyNode keyNod }, }; - private static SingleKeyboardKeyViewModel ToViewModel(CharacterKeyNode keyNode) => new() - { - DisplayText = HttpUtility.HtmlEncode(keyNode.Key.ToString()), - UnicodeIcon = null - }; + private static SingleKeyboardKeyViewModel ToViewModel(CharacterKeyNode keyNode) => + new() { DisplayText = HttpUtility.HtmlEncode(keyNode.Key.ToString()), UnicodeIcon = null }; private static SingleKeyboardKeyViewModel ToViewModel(NamedKeyNode keyNode) => ViewModelMapping[keyNode.Key]; - private static FrozenDictionary ViewModelMapping { get; } = - Enum.GetValues().ToFrozenDictionary(k => k, GetDisplayModel); + private static FrozenDictionary ViewModelMapping + { + get; + } = Enum.GetValues().ToFrozenDictionary(k => k, GetDisplayModel); - private static SingleKeyboardKeyViewModel GetDisplayModel(NamedKeyboardKey key) => - key switch - { - // Modifier keys with special symbols - NamedKeyboardKey.Command => new SingleKeyboardKeyViewModel - { - DisplayText = "Cmd", - UnicodeIcon = "⌘", - AriaLabel = "Command" - }, - NamedKeyboardKey.Shift => new SingleKeyboardKeyViewModel - { - DisplayText = "Shift", - UnicodeIcon = "⇧" - }, - NamedKeyboardKey.Ctrl => new SingleKeyboardKeyViewModel - { - DisplayText = "Ctrl", - UnicodeIcon = "⌃", - AriaLabel = "Control" - }, - NamedKeyboardKey.Alt => new SingleKeyboardKeyViewModel - { - DisplayText = "Alt", - UnicodeIcon = "⌥" - }, - NamedKeyboardKey.Option => new SingleKeyboardKeyViewModel - { - DisplayText = "Opt", - UnicodeIcon = "⌥", - AriaLabel = "Option" - }, - NamedKeyboardKey.Win => new SingleKeyboardKeyViewModel - { - DisplayText = "Win", - UnicodeIcon = "⊞", - AriaLabel = "Windows" - }, - // Directional keys - NamedKeyboardKey.Up => new SingleKeyboardKeyViewModel - { - DisplayText = "Up", - UnicodeIcon = "↑", - AriaLabel = "Up Arrow" - }, - NamedKeyboardKey.Down => new SingleKeyboardKeyViewModel - { - DisplayText = "Down", - UnicodeIcon = "↓", - AriaLabel = "Down Arrow" - }, - NamedKeyboardKey.Left => new SingleKeyboardKeyViewModel - { - DisplayText = "Left", - UnicodeIcon = "←", - AriaLabel = "Left Arrow" - }, - NamedKeyboardKey.Right => new SingleKeyboardKeyViewModel - { - DisplayText = "Right", - UnicodeIcon = "→", - AriaLabel = "Right Arrow" - }, - // Other special keys with symbols - NamedKeyboardKey.Enter => new SingleKeyboardKeyViewModel - { - DisplayText = "Enter", - UnicodeIcon = "↵" - }, - NamedKeyboardKey.Escape => new SingleKeyboardKeyViewModel - { - DisplayText = "Esc", - UnicodeIcon = "⎋", - AriaLabel = "Escape" - }, - NamedKeyboardKey.Tab => new SingleKeyboardKeyViewModel - { - DisplayText = "Tab", - UnicodeIcon = "↹", - AriaLabel = "Tab" - }, - NamedKeyboardKey.Backspace => new SingleKeyboardKeyViewModel - { - DisplayText = "Backspace", - UnicodeIcon = "⌫" - }, - NamedKeyboardKey.Delete => new SingleKeyboardKeyViewModel - { - DisplayText = "Del", - AriaLabel = "Delete" - }, - NamedKeyboardKey.Home => new SingleKeyboardKeyViewModel - { - DisplayText = "Home", - UnicodeIcon = "⇱" - }, - NamedKeyboardKey.End => new SingleKeyboardKeyViewModel - { - DisplayText = "End", - UnicodeIcon = "⇲" - }, - NamedKeyboardKey.PageUp => new SingleKeyboardKeyViewModel - { - DisplayText = "PageUp", - UnicodeIcon = "⇞", - AriaLabel = "Page Up" - }, - NamedKeyboardKey.PageDown => new SingleKeyboardKeyViewModel - { - DisplayText = "PageDown", - UnicodeIcon = "⇟", - AriaLabel = "Page Down" - }, - NamedKeyboardKey.Space => new SingleKeyboardKeyViewModel - { - DisplayText = "Space", - UnicodeIcon = "␣" - }, - NamedKeyboardKey.Insert => new SingleKeyboardKeyViewModel - { - DisplayText = "Ins", - AriaLabel = "Insert" - }, - NamedKeyboardKey.Plus => new SingleKeyboardKeyViewModel - { - DisplayText = "+", - }, - NamedKeyboardKey.Pipe => new SingleKeyboardKeyViewModel - { - DisplayText = "|", - AriaLabel = "Pipe" - }, - NamedKeyboardKey.Fn => new SingleKeyboardKeyViewModel - { - DisplayText = "Fn", - AriaLabel = "Function key" - }, - NamedKeyboardKey.F1 => new SingleKeyboardKeyViewModel - { - DisplayText = "F1", - }, - NamedKeyboardKey.F2 => new SingleKeyboardKeyViewModel - { - DisplayText = "F2", - }, - NamedKeyboardKey.F3 => new SingleKeyboardKeyViewModel - { - DisplayText = "F3", - }, - NamedKeyboardKey.F4 => new SingleKeyboardKeyViewModel - { - DisplayText = "F4", - }, - NamedKeyboardKey.F5 => new SingleKeyboardKeyViewModel - { - DisplayText = "F5", - UnicodeIcon = null - }, - NamedKeyboardKey.F6 => new SingleKeyboardKeyViewModel - { - DisplayText = "F6", - }, - NamedKeyboardKey.F7 => new SingleKeyboardKeyViewModel - { - DisplayText = "F7", - }, - NamedKeyboardKey.F8 => new SingleKeyboardKeyViewModel - { - DisplayText = "F8", - }, - NamedKeyboardKey.F9 => new SingleKeyboardKeyViewModel - { - DisplayText = "F9", - }, - NamedKeyboardKey.F10 => new SingleKeyboardKeyViewModel - { - DisplayText = "F10", - }, - NamedKeyboardKey.F11 => new SingleKeyboardKeyViewModel - { - DisplayText = "F11", - }, - NamedKeyboardKey.F12 => new SingleKeyboardKeyViewModel - { - DisplayText = "F12", - }, - // Function keys - _ => throw new ArgumentOutOfRangeException(nameof(key), key, null) - }; + private static SingleKeyboardKeyViewModel GetDisplayModel(NamedKeyboardKey key) => key switch + { + // Modifier keys with special symbols + NamedKeyboardKey.Command => new SingleKeyboardKeyViewModel { DisplayText = "Cmd", UnicodeIcon = "⌘", AriaLabel = "Command" }, + NamedKeyboardKey.Shift => new SingleKeyboardKeyViewModel { DisplayText = "Shift", UnicodeIcon = "⇧" }, + NamedKeyboardKey.Ctrl => new SingleKeyboardKeyViewModel { DisplayText = "Ctrl", UnicodeIcon = "⌃", AriaLabel = "Control" }, + NamedKeyboardKey.Alt => new SingleKeyboardKeyViewModel { DisplayText = "Alt", UnicodeIcon = "⌥" }, + NamedKeyboardKey.Option => new SingleKeyboardKeyViewModel { DisplayText = "Opt", UnicodeIcon = "⌥", AriaLabel = "Option" }, + NamedKeyboardKey.Win => new SingleKeyboardKeyViewModel { DisplayText = "Win", UnicodeIcon = "⊞", AriaLabel = "Windows" }, + // Directional keys + NamedKeyboardKey.Up => new SingleKeyboardKeyViewModel { DisplayText = "Up", UnicodeIcon = "↑", AriaLabel = "Up Arrow" }, + NamedKeyboardKey.Down => new SingleKeyboardKeyViewModel { DisplayText = "Down", UnicodeIcon = "↓", AriaLabel = "Down Arrow" }, + NamedKeyboardKey.Left => new SingleKeyboardKeyViewModel { DisplayText = "Left", UnicodeIcon = "←", AriaLabel = "Left Arrow" }, + NamedKeyboardKey.Right => new SingleKeyboardKeyViewModel { DisplayText = "Right", UnicodeIcon = "→", AriaLabel = "Right Arrow" }, + // Other special keys with symbols + NamedKeyboardKey.Enter => new SingleKeyboardKeyViewModel { DisplayText = "Enter", UnicodeIcon = "↵" }, + NamedKeyboardKey.Escape => new SingleKeyboardKeyViewModel { DisplayText = "Esc", UnicodeIcon = "⎋", AriaLabel = "Escape" }, + NamedKeyboardKey.Tab => new SingleKeyboardKeyViewModel { DisplayText = "Tab", UnicodeIcon = "↹", AriaLabel = "Tab" }, + NamedKeyboardKey.Backspace => new SingleKeyboardKeyViewModel { DisplayText = "Backspace", UnicodeIcon = "⌫" }, + NamedKeyboardKey.Delete => new SingleKeyboardKeyViewModel { DisplayText = "Del", AriaLabel = "Delete" }, + NamedKeyboardKey.Home => new SingleKeyboardKeyViewModel { DisplayText = "Home", UnicodeIcon = "⇱" }, + NamedKeyboardKey.End => new SingleKeyboardKeyViewModel { DisplayText = "End", UnicodeIcon = "⇲" }, + NamedKeyboardKey.PageUp => new SingleKeyboardKeyViewModel { DisplayText = "PageUp", UnicodeIcon = "⇞", AriaLabel = "Page Up" }, + NamedKeyboardKey.PageDown => + new SingleKeyboardKeyViewModel { DisplayText = "PageDown", UnicodeIcon = "⇟", AriaLabel = "Page Down" }, + NamedKeyboardKey.Space => new SingleKeyboardKeyViewModel { DisplayText = "Space", UnicodeIcon = "␣" }, + NamedKeyboardKey.Insert => new SingleKeyboardKeyViewModel { DisplayText = "Ins", AriaLabel = "Insert" }, + NamedKeyboardKey.Plus => new SingleKeyboardKeyViewModel { DisplayText = "+", }, + NamedKeyboardKey.Pipe => new SingleKeyboardKeyViewModel { DisplayText = "|", AriaLabel = "Pipe" }, + NamedKeyboardKey.Fn => new SingleKeyboardKeyViewModel { DisplayText = "Fn", AriaLabel = "Function key" }, + NamedKeyboardKey.F1 => new SingleKeyboardKeyViewModel { DisplayText = "F1", }, + NamedKeyboardKey.F2 => new SingleKeyboardKeyViewModel { DisplayText = "F2", }, + NamedKeyboardKey.F3 => new SingleKeyboardKeyViewModel { DisplayText = "F3", }, + NamedKeyboardKey.F4 => new SingleKeyboardKeyViewModel { DisplayText = "F4", }, + NamedKeyboardKey.F5 => new SingleKeyboardKeyViewModel { DisplayText = "F5", UnicodeIcon = null }, + NamedKeyboardKey.F6 => new SingleKeyboardKeyViewModel { DisplayText = "F6", }, + NamedKeyboardKey.F7 => new SingleKeyboardKeyViewModel { DisplayText = "F7", }, + NamedKeyboardKey.F8 => new SingleKeyboardKeyViewModel { DisplayText = "F8", }, + NamedKeyboardKey.F9 => new SingleKeyboardKeyViewModel { DisplayText = "F9", }, + NamedKeyboardKey.F10 => new SingleKeyboardKeyViewModel { DisplayText = "F10", }, + NamedKeyboardKey.F11 => new SingleKeyboardKeyViewModel { DisplayText = "F11", }, + NamedKeyboardKey.F12 => new SingleKeyboardKeyViewModel { DisplayText = "F12", }, + // Function keys + _ => throw new ArgumentOutOfRangeException(nameof(key), key, null) + }; } [EnumExtensions] public enum NamedKeyboardKey { // Modifier Keys - [Display(Name = "shift")] Shift, - [Display(Name = "ctrl")] Ctrl, - [Display(Name = "alt")] Alt, - [Display(Name = "option")] Option, - [Display(Name = "cmd")] Command, - [Display(Name = "win")] Win, + [Display(Name = "shift")] + Shift, + [Display(Name = "ctrl")] + Ctrl, + [Display(Name = "alt")] + Alt, + [Display(Name = "option")] + Option, + [Display(Name = "cmd")] + Command, + [Display(Name = "win")] + Win, // Directional Keys - [Display(Name = "up")] Up, - [Display(Name = "down")] Down, - [Display(Name = "left")] Left, - [Display(Name = "right")] Right, + [Display(Name = "up")] + Up, + [Display(Name = "down")] + Down, + [Display(Name = "left")] + Left, + [Display(Name = "right")] + Right, // Control Keys - [Display(Name = "space")] Space, - [Display(Name = "tab")] Tab, - [Display(Name = "enter")] Enter, - [Display(Name = "esc")] Escape, - [Display(Name = "backspace")] Backspace, - [Display(Name = "del")] Delete, - [Display(Name = "ins")] Insert, + [Display(Name = "space")] + Space, + [Display(Name = "tab")] + Tab, + [Display(Name = "enter")] + Enter, + [Display(Name = "esc")] + Escape, + [Display(Name = "backspace")] + Backspace, + [Display(Name = "del")] + Delete, + [Display(Name = "ins")] + Insert, // Navigation Keys - [Display(Name = "pageup")] PageUp, - [Display(Name = "pagedown")] PageDown, - [Display(Name = "home")] Home, - [Display(Name = "end")] End, + [Display(Name = "pageup")] + PageUp, + [Display(Name = "pagedown")] + PageDown, + [Display(Name = "home")] + Home, + [Display(Name = "end")] + End, // Function Keys - [Display(Name = "f1")] F1, - [Display(Name = "f2")] F2, - [Display(Name = "f3")] F3, - [Display(Name = "f4")] F4, - [Display(Name = "f5")] F5, - [Display(Name = "f6")] F6, - [Display(Name = "f7")] F7, - [Display(Name = "f8")] F8, - [Display(Name = "f9")] F9, - [Display(Name = "f10")] F10, - [Display(Name = "f11")] F11, - [Display(Name = "f12")] F12, + [Display(Name = "f1")] + F1, + [Display(Name = "f2")] + F2, + [Display(Name = "f3")] + F3, + [Display(Name = "f4")] + F4, + [Display(Name = "f5")] + F5, + [Display(Name = "f6")] + F6, + [Display(Name = "f7")] + F7, + [Display(Name = "f8")] + F8, + [Display(Name = "f9")] + F9, + [Display(Name = "f10")] + F10, + [Display(Name = "f11")] + F11, + [Display(Name = "f12")] + F12, // Other Keys - [Display(Name = "plus")] Plus, - [Display(Name = "fn")] Fn, - [Display(Name = "pipe")] Pipe + [Display(Name = "plus")] + Plus, + [Display(Name = "fn")] + Fn, + [Display(Name = "pipe")] + Pipe } public class IKeyNode; diff --git a/src/Elastic.Markdown/Myst/Roles/Kbd/KbdParser.cs b/src/Elastic.Markdown/Myst/Roles/Kbd/KbdParser.cs index 59cc0c56b6..9ef6f9b6bd 100644 --- a/src/Elastic.Markdown/Myst/Roles/Kbd/KbdParser.cs +++ b/src/Elastic.Markdown/Myst/Roles/Kbd/KbdParser.cs @@ -9,8 +9,7 @@ namespace Elastic.Markdown.Myst.Roles.Kbd; public class KbdParser : RoleParser { - protected override KbdRole CreateRole(string role, string content, InlineProcessor parserContext) - => new(role, content, parserContext); + protected override KbdRole CreateRole(string role, string content, InlineProcessor parserContext) => new(role, content, parserContext); protected override bool Matches(ReadOnlySpan role) => role is "{kbd}"; } diff --git a/src/Elastic.Markdown/Myst/Roles/Math/MathParser.cs b/src/Elastic.Markdown/Myst/Roles/Math/MathParser.cs index 2c648e44b9..9f73ab9f5e 100644 --- a/src/Elastic.Markdown/Myst/Roles/Math/MathParser.cs +++ b/src/Elastic.Markdown/Myst/Roles/Math/MathParser.cs @@ -8,8 +8,7 @@ namespace Elastic.Markdown.Myst.Roles.Math; public class MathParser : RoleParser { - protected override MathRole CreateRole(string role, string content, InlineProcessor parserContext) - => new(role, content); + protected override MathRole CreateRole(string role, string content, InlineProcessor parserContext) => new(role, content); protected override bool Matches(ReadOnlySpan role) => role is "{math}"; } diff --git a/src/Elastic.Markdown/Myst/Roles/Role.cs b/src/Elastic.Markdown/Myst/Roles/Role.cs index 5ecb67db81..18e95a113b 100644 --- a/src/Elastic.Markdown/Myst/Roles/Role.cs +++ b/src/Elastic.Markdown/Myst/Roles/Role.cs @@ -6,7 +6,6 @@ namespace Elastic.Markdown.Myst.Roles; - //TODO evaluate if we need this /// /// An inline custom container diff --git a/src/Elastic.Markdown/Myst/Roles/RoleParser.cs b/src/Elastic.Markdown/Myst/Roles/RoleParser.cs index 26f2539f74..e028dee7e3 100644 --- a/src/Elastic.Markdown/Myst/Roles/RoleParser.cs +++ b/src/Elastic.Markdown/Myst/Roles/RoleParser.cs @@ -18,8 +18,7 @@ public abstract class RoleLeaf(string role, string content) : CodeInline(content public string Role => role; } -public abstract class RoleParser : InlineParser - where TRole : RoleLeaf +public abstract class RoleParser : InlineParser where TRole : RoleLeaf { protected RoleParser() => OpeningCharacters = ['{']; @@ -103,8 +102,7 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice) if (processor.TrackTrivia) { // startPosition and slice.Start include the opening/closing sticks. - leaf.ContentWithTrivia = - new StringSlice(slice.Text, startPosition + openSticks, slice.Start - openSticks - 1); + leaf.ContentWithTrivia = new StringSlice(slice.Text, startPosition + openSticks, slice.Start - openSticks - 1); } processor.Inline = leaf; diff --git a/src/Elastic.Markdown/Myst/YamlSerialization.cs b/src/Elastic.Markdown/Myst/YamlSerialization.cs index 65ed9abcf7..3b94b5f99d 100644 --- a/src/Elastic.Markdown/Myst/YamlSerialization.cs +++ b/src/Elastic.Markdown/Myst/YamlSerialization.cs @@ -67,8 +67,7 @@ internal class ListingFrontMatterConverter : IYamlTypeConverter return result; } - public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => - serializer.Invoke(value, type); + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => serializer.Invoke(value, type); } [YamlStaticContext] diff --git a/src/Elastic.Markdown/Page/IPageViewFactory.cs b/src/Elastic.Markdown/Page/IPageViewFactory.cs index 16160abf65..6c534b61dd 100644 --- a/src/Elastic.Markdown/Page/IPageViewFactory.cs +++ b/src/Elastic.Markdown/Page/IPageViewFactory.cs @@ -24,6 +24,5 @@ public interface IPageViewFactory public class DefaultPageViewFactory : IPageViewFactory { /// - public RazorSlice Create(IndexViewModel viewModel) => - Index.Create(viewModel); + public RazorSlice Create(IndexViewModel viewModel) => Index.Create(viewModel); } diff --git a/src/Elastic.Markdown/Page/IndexViewModel.cs b/src/Elastic.Markdown/Page/IndexViewModel.cs index e2644f6188..57135b5951 100644 --- a/src/Elastic.Markdown/Page/IndexViewModel.cs +++ b/src/Elastic.Markdown/Page/IndexViewModel.cs @@ -124,13 +124,19 @@ public class VersionDropDownItemViewModel Name = versionGroup.Key, Href = null, IsDisabled = false, - Children = versionGroup.Value.Select(v => new VersionDropDownItemViewModel - { - Name = v, - Href = legacyPageMappings.First(x => x.Version == v).ToString(), - IsDisabled = !legacyPageMappings.First(x => x.Version == v).Exists, - Children = null - }).ToArray() + Children = + versionGroup.Value + .Select( + v => + new VersionDropDownItemViewModel + { + Name = v, + Href = legacyPageMappings.First(x => x.Version == v).ToString(), + IsDisabled = !legacyPageMappings.First(x => x.Version == v).Exists, + Children = null + } + ) + .ToArray() }); } else @@ -154,18 +160,21 @@ public class VersionDropDownItemViewModel // But in the actual dropdown, we want to group them by major version // E.g., 8.0 – 8.18 should be grouped under 8.x private static Dictionary> GroupByMajorVersion(LegacyPageMapping[] legacyPageMappings) => - legacyPageMappings.Aggregate>>([], (acc, curr) => - { - var major = curr.Version.Split('.')[0]; - if (!int.TryParse(major, out _)) + legacyPageMappings.Aggregate>>( + [], + (acc, curr) => + { + var major = curr.Version.Split('.')[0]; + if (!int.TryParse(major, out _)) + return acc; + var key = $"{major}.x"; + if (!acc.TryGetValue(key, out var value)) + acc[key] = [curr.Version]; + else + value.Add(curr.Version); return acc; - var key = $"{major}.x"; - if (!acc.TryGetValue(key, out var value)) - acc[key] = [curr.Version]; - else - value.Add(curr.Version); - return acc; - }); + } + ); } [JsonSerializable(typeof(VersionDropDownItemViewModel[]))] diff --git a/src/api/Elastic.Documentation.Api/Adapters/AskAi/AgentBuilderAskAiGateway.cs b/src/api/Elastic.Documentation.Api/Adapters/AskAi/AgentBuilderAskAiGateway.cs index aba730b558..55c5e2f0d3 100644 --- a/src/api/Elastic.Documentation.Api/Adapters/AskAi/AgentBuilderAskAiGateway.cs +++ b/src/api/Elastic.Documentation.Api/Adapters/AskAi/AgentBuilderAskAiGateway.cs @@ -12,7 +12,11 @@ namespace Elastic.Documentation.Api.Adapters.AskAi; -public class AgentBuilderAskAiGateway(HttpClient httpClient, KibanaOptions kibanaOptions, ILogger logger) : IAskAiService +public class AgentBuilderAskAiGateway( + HttpClient httpClient, + KibanaOptions kibanaOptions, + ILogger logger +) : IAskAiService { /// /// Model name used by Agent Builder (from AgentId) @@ -27,16 +31,15 @@ public async Task AskAi(AskAiRequest askAiRequest, Cancel { // Agent Builder returns the conversation ID in the stream via conversation_id_set event // We don't generate IDs - Agent Builder handles that in the stream - var agentBuilderPayload = new AgentBuilderPayload( - askAiRequest.Message, - "docs-agent", - askAiRequest.ConversationId?.ToString()); + var agentBuilderPayload = new AgentBuilderPayload(askAiRequest.Message, "docs-agent", askAiRequest.ConversationId?.ToString()); var requestBody = JsonSerializer.Serialize(agentBuilderPayload, AgentBuilderContext.Default.AgentBuilderPayload); - logger.LogInformation("Sending to Agent Builder with conversation_id: \"{ConversationId}\"", askAiRequest.ConversationId?.ToString() ?? "(null - first request)"); + logger.LogInformation( + "Sending to Agent Builder with conversation_id: \"{ConversationId}\"", + askAiRequest.ConversationId?.ToString() ?? "(null - first request)" + ); - using var request = new HttpRequestMessage(HttpMethod.Post, - $"{kibanaOptions.Url}/api/agent_builder/converse/async"); + using var request = new HttpRequestMessage(HttpMethod.Post, $"{kibanaOptions.Url}/api/agent_builder/converse/async"); request.Content = new StringContent(requestBody, Encoding.UTF8, "application/json"); request.Headers.Add("kbn-xsrf", "true"); request.Headers.Authorization = new AuthenticationHeaderValue("ApiKey", kibanaOptions.ApiKey); @@ -54,7 +57,10 @@ public async Task AskAi(AskAiRequest askAiRequest, Cancel // Log response details for debugging logger.LogInformation("Response Content-Type: {ContentType}", response.Content.Headers.ContentType?.ToString()); - logger.LogInformation("Response Content-Length: {ContentLength}", response.Content.Headers.ContentLength?.ToString(CultureInfo.InvariantCulture)); + logger.LogInformation( + "Response Content-Length: {ContentLength}", + response.Content.Headers.ContentLength?.ToString(CultureInfo.InvariantCulture) + ); // Agent Builder already returns SSE format, just return the stream directly // The conversation ID will be extracted from the stream by the transformer diff --git a/src/api/Elastic.Documentation.Api/Adapters/AskAi/AgentBuilderStreamTransformer.cs b/src/api/Elastic.Documentation.Api/Adapters/AskAi/AgentBuilderStreamTransformer.cs index a7e0e49679..3ae04ba0f5 100644 --- a/src/api/Elastic.Documentation.Api/Adapters/AskAi/AgentBuilderStreamTransformer.cs +++ b/src/api/Elastic.Documentation.Api/Adapters/AskAi/AgentBuilderStreamTransformer.cs @@ -41,33 +41,23 @@ public class AgentBuilderStreamTransformer(ILogger new AskAiEvent.ConversationStart(id, timestamp, convId.GetString()!), - "message_chunk" when innerData.TryGetProperty("text_chunk", out var textChunk) => new AskAiEvent.MessageChunk(id, timestamp, textChunk.GetString()!), - "message_complete" when innerData.TryGetProperty("message_content", out var fullContent) => new AskAiEvent.MessageComplete(id, timestamp, fullContent.GetString()!), - "reasoning" => // Parse reasoning message if available ParseReasoningEvent(id, timestamp, innerData), - "tool_call" => // Parse tool call ParseToolCallEvent(id, timestamp, innerData), - "tool_result" => // Parse tool result ParseToolResultEvent(id, timestamp, innerData), - - "round_complete" => - new AskAiEvent.ConversationEnd(id, timestamp), - - "conversation_created" => - null, // Skip, already handled by conversation_id_set + "round_complete" => new AskAiEvent.ConversationEnd(id, timestamp), + "conversation_created" => null, // Skip, already handled by conversation_id_set _ => LogUnknownEvent(type, json) }; @@ -82,9 +72,7 @@ public class AgentBuilderStreamTransformer(ILogger logger) : IAskAiService + ILogger logger +) : IAskAiService { public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx = default) { diff --git a/src/api/Elastic.Documentation.Api/Adapters/AskAi/ElasticsearchAskAiMessageFeedbackGateway.cs b/src/api/Elastic.Documentation.Api/Adapters/AskAi/ElasticsearchAskAiMessageFeedbackGateway.cs index d01d751b4e..0355182c5f 100644 --- a/src/api/Elastic.Documentation.Api/Adapters/AskAi/ElasticsearchAskAiMessageFeedbackGateway.cs +++ b/src/api/Elastic.Documentation.Api/Adapters/AskAi/ElasticsearchAskAiMessageFeedbackGateway.cs @@ -27,7 +27,8 @@ public sealed class ElasticsearchAskAiMessageFeedbackGateway : IAskAiMessageFeed public ElasticsearchAskAiMessageFeedbackGateway( DocumentationEndpoints endpoints, AppEnvironment appEnvironment, - ILogger logger) + ILogger logger + ) { _logger = logger; _indexName = $"ask-ai-message-feedback-{appEnvironment.Current.ToStringFast(true)}"; @@ -36,16 +37,12 @@ public ElasticsearchAskAiMessageFeedbackGateway( _nodePool = new SingleNodePool(endpoint.Uri); var auth = endpoint.ApiKey is { } apiKey ? (AuthorizationHeader)new ApiKey(apiKey) - : endpoint is { Username: { } username, Password: { } password } - ? new BasicAuthentication(username, password) - : null!; + : endpoint is { Username: { } username, Password: { } password } ? new BasicAuthentication(username, password) : null!; using var clientSettings = new ElasticsearchClientSettings( - _nodePool, - sourceSerializer: (_, settings) => new DefaultSourceSerializer(settings, MessageFeedbackJsonContext.Default) - ) - .DefaultIndex(_indexName) - .Authentication(auth); + _nodePool, + sourceSerializer: (_, settings) => new DefaultSourceSerializer(settings, MessageFeedbackJsonContext.Default) + ).DefaultIndex(_indexName).Authentication(auth); _client = new ElasticsearchClient(clientSettings); } @@ -74,9 +71,8 @@ public async Task RecordFeedbackAsync(AskAiMessageFeedbackRecord record, Cancell _logger.LogDebug("Indexing feedback with ID {FeedbackId} to index {IndexName}", feedbackId, _indexName); - var response = await _client.IndexAsync(document, idx => idx - .Index(_indexName) - .Id(feedbackId.ToString()), ctx); + var response = + await _client.IndexAsync(document, idx => idx.Index(_indexName).Id(feedbackId.ToString()), ctx); // MessageId and ConversationId are Guid types, so no sanitization needed if (!response.IsValidResponse) @@ -84,7 +80,8 @@ public async Task RecordFeedbackAsync(AskAiMessageFeedbackRecord record, Cancell _logger.LogWarning( "Failed to index message feedback for message {MessageId}: {Error}", record.MessageId, - response.ElasticsearchServerError?.Error?.Reason ?? "Unknown error"); + response.ElasticsearchServerError?.Error?.Reason ?? "Unknown error" + ); } else { @@ -94,7 +91,8 @@ public async Task RecordFeedbackAsync(AskAiMessageFeedbackRecord record, Cancell record.MessageId, record.ConversationId, response.Id, - response.Index); + response.Index + ); } } } diff --git a/src/api/Elastic.Documentation.Api/Adapters/AskAi/KibanaOptions.cs b/src/api/Elastic.Documentation.Api/Adapters/AskAi/KibanaOptions.cs index df335c3cfc..609208b822 100644 --- a/src/api/Elastic.Documentation.Api/Adapters/AskAi/KibanaOptions.cs +++ b/src/api/Elastic.Documentation.Api/Adapters/AskAi/KibanaOptions.cs @@ -8,8 +8,8 @@ namespace Elastic.Documentation.Api.Adapters.AskAi; public class KibanaOptions(IConfiguration configuration) { - public string Url { get; } = configuration["DOCUMENTATION_KIBANA_URL"] - ?? throw new InvalidOperationException("DOCUMENTATION_KIBANA_URL not configured"); - public string ApiKey { get; } = configuration["DOCUMENTATION_KIBANA_APIKEY"] - ?? throw new InvalidOperationException("DOCUMENTATION_KIBANA_APIKEY not configured"); + public string Url { get; } = configuration["DOCUMENTATION_KIBANA_URL"] ?? + throw new InvalidOperationException("DOCUMENTATION_KIBANA_URL not configured"); + public string ApiKey { get; } = configuration["DOCUMENTATION_KIBANA_APIKEY"] ?? + throw new InvalidOperationException("DOCUMENTATION_KIBANA_APIKEY not configured"); } diff --git a/src/api/Elastic.Documentation.Api/Adapters/AskAi/LlmGatewayAskAiGateway.cs b/src/api/Elastic.Documentation.Api/Adapters/AskAi/LlmGatewayAskAiGateway.cs index fa8c90213e..fd07ef571a 100644 --- a/src/api/Elastic.Documentation.Api/Adapters/AskAi/LlmGatewayAskAiGateway.cs +++ b/src/api/Elastic.Documentation.Api/Adapters/AskAi/LlmGatewayAskAiGateway.cs @@ -68,10 +68,7 @@ public static LlmGatewayRequest CreateFromRequest(AskAiRequest request, Guid con AgentOptions: new AgentOptions(FirstGenerationTimeout: 60000), UserContext: new UserContext("elastic-docs-v3@invalid"), PlatformContext: new PlatformContext("docs_site", "docs_assistant", []), - Input: - [ - new ChatInput("user", request.Message) - ], + Input: [new ChatInput("user", request.Message)], ThreadId: conversationId.ToString() ); } @@ -80,11 +77,7 @@ public record UserContext(string UserEmail); public record AgentOptions(int FirstGenerationTimeout); -public record PlatformContext( - string Origin, - string UseCase, - Dictionary? Metadata = null -); +public record PlatformContext(string Origin, string UseCase, Dictionary? Metadata = null); public record ChatInput(string Role, string Message); diff --git a/src/api/Elastic.Documentation.Api/Adapters/AskAi/LlmGatewayOptions.cs b/src/api/Elastic.Documentation.Api/Adapters/AskAi/LlmGatewayOptions.cs index 4102ffbc8d..ee7a7dc953 100644 --- a/src/api/Elastic.Documentation.Api/Adapters/AskAi/LlmGatewayOptions.cs +++ b/src/api/Elastic.Documentation.Api/Adapters/AskAi/LlmGatewayOptions.cs @@ -9,24 +9,22 @@ namespace Elastic.Documentation.Api.Adapters.AskAi; public class LlmGatewayOptions(IConfiguration configuration) { public string ServiceAccount { get; } = ResolveServiceAccount(configuration); - public string FunctionUrl { get; } = configuration["LLM_GATEWAY_FUNCTION_URL"] - ?? throw new InvalidOperationException("LLM_GATEWAY_FUNCTION_URL not configured"); - public string TargetAudience { get; } = GetTargetAudience(configuration["LLM_GATEWAY_FUNCTION_URL"] - ?? throw new InvalidOperationException("LLM_GATEWAY_FUNCTION_URL not configured")); + public string FunctionUrl { get; } = configuration["LLM_GATEWAY_FUNCTION_URL"] ?? + throw new InvalidOperationException("LLM_GATEWAY_FUNCTION_URL not configured"); + public string TargetAudience { get; } = GetTargetAudience( + configuration["LLM_GATEWAY_FUNCTION_URL"] ?? throw new InvalidOperationException("LLM_GATEWAY_FUNCTION_URL not configured") + ); private static string ResolveServiceAccount(IConfiguration configuration) { // Auto-detect: if value is a file path that exists, read file content // Otherwise use the value directly (for Lambda with env var containing the JSON) - var serviceAccountValue = configuration["LLM_GATEWAY_SERVICE_ACCOUNT"] - ?? configuration["LLM_GATEWAY_SERVICE_ACCOUNT_KEY_PATH"]; + var serviceAccountValue = configuration["LLM_GATEWAY_SERVICE_ACCOUNT"] ?? configuration["LLM_GATEWAY_SERVICE_ACCOUNT_KEY_PATH"]; if (string.IsNullOrEmpty(serviceAccountValue)) throw new InvalidOperationException("LLM_GATEWAY_SERVICE_ACCOUNT not configured"); - return File.Exists(serviceAccountValue) - ? File.ReadAllText(serviceAccountValue) - : serviceAccountValue; + return File.Exists(serviceAccountValue) ? File.ReadAllText(serviceAccountValue) : serviceAccountValue; } private static string GetTargetAudience(string functionUrl) diff --git a/src/api/Elastic.Documentation.Api/Adapters/AskAi/LlmGatewayStreamTransformer.cs b/src/api/Elastic.Documentation.Api/Adapters/AskAi/LlmGatewayStreamTransformer.cs index 57aec70ee7..c2c83c2325 100644 --- a/src/api/Elastic.Documentation.Api/Adapters/AskAi/LlmGatewayStreamTransformer.cs +++ b/src/api/Elastic.Documentation.Api/Adapters/AskAi/LlmGatewayStreamTransformer.cs @@ -23,7 +23,13 @@ public class LlmGatewayStreamTransformer(ILogger lo /// LLM Gateway doesn't return a conversation ID, so we emit one to match Agent Builder behavior. /// The generatedConversationId is the ID generated by the gateway and used as ThreadId. /// - protected override async Task ProcessStreamAsync(PipeReader reader, PipeWriter writer, Guid? generatedConversationId, Activity? parentActivity, CancellationToken cancellationToken) + protected override async Task ProcessStreamAsync( + PipeReader reader, + PipeWriter writer, + Guid? generatedConversationId, + Activity? parentActivity, + CancellationToken cancellationToken + ) { // Emit ConversationStart event only if a new conversation ID was generated if (generatedConversationId is not null) @@ -73,25 +79,16 @@ protected override async Task ProcessStreamAsync(PipeReader reader, PipeWriter w { "ai_message_chunk" when messageData.TryGetProperty("content", out var content) => new AskAiEvent.MessageChunk(id, timestamp, content.GetString()!), - "ai_message" when messageData.TryGetProperty("content", out var fullContent) => new AskAiEvent.MessageComplete(id, timestamp, fullContent.GetString()!), - - "tool_call" when messageData.TryGetProperty("toolCalls", out var toolCalls) => - TransformToolCall(id, timestamp, toolCalls), - + "tool_call" when messageData.TryGetProperty("toolCalls", out var toolCalls) => TransformToolCall(id, timestamp, toolCalls), // Frontend only uses tool_result to show "Analyzing..." status - result content not displayed // Skip sending the large payload (~30KB) to prevent CloudFront OAC buffering issues "tool_message" when messageData.TryGetProperty("toolCallId", out var toolCallId) => new AskAiEvent.ToolResult(id, timestamp, toolCallId.GetString()!, ""), - - "agent_end" => - new AskAiEvent.ConversationEnd(id, timestamp), - + "agent_end" => new AskAiEvent.ConversationEnd(id, timestamp), "error" => ParseErrorEvent(id, timestamp, messageData), - - "chat_model_start" or "chat_model_end" => - null, // Skip model lifecycle events + "chat_model_start" or "chat_model_end" => null, // Skip model lifecycle events _ => LogUnknownEvent(type, json) }; @@ -124,13 +121,7 @@ protected override async Task ProcessStreamAsync(PipeReader reader, PipeWriter w } // Fallback to generic tool call - return new AskAiEvent.ToolCall( - id, - timestamp, - toolCallId ?? id, - toolName ?? "unknown", - args.GetRawText() - ); + return new AskAiEvent.ToolCall(id, timestamp, toolCallId ?? id, toolName ?? "unknown", args.GetRawText()); } catch (Exception ex) { @@ -150,9 +141,7 @@ private AskAiEvent.ErrorEvent ParseErrorEvent(string id, long timestamp, JsonEle // LLM Gateway error format: {error: "...", message: "..."} var errorMessage = messageData.TryGetProperty("message", out var msgProp) ? msgProp.GetString() - : messageData.TryGetProperty("error", out var errProp) - ? errProp.GetString() - : null; + : messageData.TryGetProperty("error", out var errProp) ? errProp.GetString() : null; Logger.LogError("Error event received from LLM Gateway: {ErrorMessage}", errorMessage ?? "Unknown error"); @@ -168,9 +157,7 @@ private AskAiEvent.ErrorEvent ParseAgentStreamError(JsonElement json) var id = Guid.NewGuid().ToString(); var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - var errorMessage = json.TryGetProperty("error", out var errorProp) - ? errorProp.GetString() - : null; + var errorMessage = json.TryGetProperty("error", out var errorProp) ? errorProp.GetString() : null; Logger.LogError("Agent stream error received from LLM Gateway: {ErrorMessage}", errorMessage ?? "Unknown error"); diff --git a/src/api/Elastic.Documentation.Api/Adapters/AskAi/SseParser.cs b/src/api/Elastic.Documentation.Api/Adapters/AskAi/SseParser.cs index 5cd2cb6bbc..f2f0fbdc76 100644 --- a/src/api/Elastic.Documentation.Api/Adapters/AskAi/SseParser.cs +++ b/src/api/Elastic.Documentation.Api/Adapters/AskAi/SseParser.cs @@ -101,6 +101,5 @@ private static bool TryReadLine(ref ReadOnlySequence buffer, out string li } } - [JsonSerializable(typeof(SseEvent))] internal sealed partial class SseSerializerContext : JsonSerializerContext; diff --git a/src/api/Elastic.Documentation.Api/Adapters/AskAi/StreamTransformerBase.cs b/src/api/Elastic.Documentation.Api/Adapters/AskAi/StreamTransformerBase.cs index f3871a2da3..91285582cc 100644 --- a/src/api/Elastic.Documentation.Api/Adapters/AskAi/StreamTransformerBase.cs +++ b/src/api/Elastic.Documentation.Api/Adapters/AskAi/StreamTransformerBase.cs @@ -42,13 +42,21 @@ public abstract class StreamTransformerBase(ILogger logger) : IStreamTransformer /// public string AgentProvider => GetAgentProvider(); - public Task TransformAsync(Stream rawStream, Guid? generatedConversationId, Activity? parentActivity, Cancel cancellationToken = default) + public Task TransformAsync( + Stream rawStream, + Guid? generatedConversationId, + Activity? parentActivity, + Cancel cancellationToken = default + ) { // Configure pipe for low-latency streaming var pipeOptions = new PipeOptions( minimumSegmentSize: 1024, // Smaller segments for faster processing + pauseWriterThreshold: 64 * 1024, // 64KB high water mark + resumeWriterThreshold: 32 * 1024, // 32KB low water mark + readerScheduler: PipeScheduler.Inline, writerScheduler: PipeScheduler.Inline, useSynchronizationContext: false @@ -71,7 +79,13 @@ public Task TransformAsync(Stream rawStream, Guid? generatedConversation /// Process the pipe reader and write transformed events to the pipe writer. /// This runs concurrently with the consumer reading from the output stream. ///
- private async Task ProcessPipeAsync(PipeReader reader, PipeWriter writer, Guid? generatedConversationId, Activity? parentActivity, CancellationToken cancellationToken) + private async Task ProcessPipeAsync( + PipeReader reader, + PipeWriter writer, + Guid? generatedConversationId, + Activity? parentActivity, + CancellationToken cancellationToken + ) { using var activityScope = parentActivity; try @@ -84,7 +98,11 @@ private async Task ProcessPipeAsync(PipeReader reader, PipeWriter writer, Guid? } catch (Exception ex) { - Logger.LogError(ex, "Error transforming stream for transformer {TransformerType}. Stream processing will be terminated.", GetType().Name); + Logger.LogError( + ex, + "Error transforming stream for transformer {TransformerType}. Stream processing will be terminated.", + GetType().Name + ); _ = parentActivity?.SetTag("error.type", ex.GetType().Name); try { @@ -94,7 +112,11 @@ private async Task ProcessPipeAsync(PipeReader reader, PipeWriter writer, Guid? } catch (Exception completeEx) { - Logger.LogError(completeEx, "Error completing pipe after transformation error for transformer {TransformerType}", GetType().Name); + Logger.LogError( + completeEx, + "Error completing pipe after transformation error for transformer {TransformerType}", + GetType().Name + ); } return; } @@ -110,13 +132,18 @@ private async Task ProcessPipeAsync(PipeReader reader, PipeWriter writer, Guid? } } - /// /// Process the raw stream and write transformed events to the pipe writer. /// Default implementation parses SSE events and JSON, then calls TransformJsonEvent. /// /// Stream processing result with metrics and captured output - protected virtual async Task ProcessStreamAsync(PipeReader reader, PipeWriter writer, Guid? generatedConversationId, Activity? parentActivity, CancellationToken cancellationToken) + protected virtual async Task ProcessStreamAsync( + PipeReader reader, + PipeWriter writer, + Guid? generatedConversationId, + Activity? parentActivity, + CancellationToken cancellationToken + ) { using var activity = StreamTransformerActivitySource.StartActivity("process ask_ai stream", ActivityKind.Internal); @@ -138,15 +165,23 @@ protected virtual async Task ProcessStreamAsync(PipeReader reader, PipeWriter wr } catch (JsonException ex) { - Logger.LogError(ex, "Failed to parse JSON from SSE event for transformer {TransformerType}. EventType: {EventType}, Data: {Data}", - GetType().Name, sseEvent.EventType, sseEvent.Data); + Logger.LogError( + ex, + "Failed to parse JSON from SSE event for transformer {TransformerType}. EventType: {EventType}, Data: {Data}", + GetType().Name, + sseEvent.EventType, + sseEvent.Data + ); throw; } if (transformedEvent == null) { - Logger.LogWarning("Transformed event is null for transformer {TransformerType}. Skipping event. EventType: {EventType}", - GetType().Name, sseEvent.EventType); + Logger.LogWarning( + "Transformed event is null for transformer {TransformerType}. Skipping event. EventType: {EventType}", + GetType().Name, + sseEvent.EventType + ); Logger.LogWarning("Original event: {event}", JsonSerializer.Serialize(sseEvent, SseSerializerContext.Default.SseEvent)); continue; } @@ -179,7 +214,6 @@ protected virtual async Task ProcessStreamAsync(PipeReader reader, PipeWriter wr // Event type already tagged above break; } - case AskAiEvent.ErrorEvent errorEvent: { _ = activity?.SetStatus(ActivityStatusCode.Error, "AI provider error event"); @@ -260,8 +294,12 @@ protected async Task WriteEventAsync(AskAiEvent? transformedEvent, PipeWriter wr } catch (Exception ex) { - Logger.LogError(ex, "Error writing event to stream for transformer {TransformerType}. EventType: {EventType}", - GetType().Name, transformedEvent.GetType().Name); + Logger.LogError( + ex, + "Error writing event to stream for transformer {TransformerType}. EventType: {EventType}", + GetType().Name, + transformedEvent.GetType().Name + ); throw; // Re-throw to be handled by caller } } diff --git a/src/api/Elastic.Documentation.Api/Adapters/AskAi/StreamTransformerFactory.cs b/src/api/Elastic.Documentation.Api/Adapters/AskAi/StreamTransformerFactory.cs index 7cbb9ff3c6..0fd1685000 100644 --- a/src/api/Elastic.Documentation.Api/Adapters/AskAi/StreamTransformerFactory.cs +++ b/src/api/Elastic.Documentation.Api/Adapters/AskAi/StreamTransformerFactory.cs @@ -14,7 +14,8 @@ namespace Elastic.Documentation.Api.Adapters.AskAi; public class StreamTransformerFactory( IServiceProvider serviceProvider, AskAiProviderResolver providerResolver, - ILogger logger) : IStreamTransformer + ILogger logger +) : IStreamTransformer { private IStreamTransformer? _resolvedTransformer; @@ -39,7 +40,12 @@ private IStreamTransformer GetTransformer() public string AgentId => GetTransformer().AgentId; public string AgentProvider => GetTransformer().AgentProvider; - public async Task TransformAsync(Stream rawStream, Guid? generatedConversationId, System.Diagnostics.Activity? parentActivity, Cancel cancellationToken = default) + public async Task TransformAsync( + Stream rawStream, + Guid? generatedConversationId, + System.Diagnostics.Activity? parentActivity, + Cancel cancellationToken = default + ) { var transformer = GetTransformer(); return await transformer.TransformAsync(rawStream, generatedConversationId, parentActivity, cancellationToken); diff --git a/src/api/Elastic.Documentation.Api/AskAi/AskAiEvent.cs b/src/api/Elastic.Documentation.Api/AskAi/AskAiEvent.cs index bb77ea4baa..24d1661372 100644 --- a/src/api/Elastic.Documentation.Api/AskAi/AskAiEvent.cs +++ b/src/api/Elastic.Documentation.Api/AskAi/AskAiEvent.cs @@ -24,86 +24,50 @@ public abstract record AskAiEvent(string Id, long Timestamp) /// /// Conversation has started /// - public sealed record ConversationStart( - string Id, - long Timestamp, - string ConversationId - ) : AskAiEvent(Id, Timestamp); + public sealed record ConversationStart(string Id, long Timestamp, string ConversationId) : AskAiEvent(Id, Timestamp); /// /// Streaming text chunk from AI /// - public sealed record MessageChunk( - string Id, - long Timestamp, - string Content - ) : AskAiEvent(Id, Timestamp); + public sealed record MessageChunk(string Id, long Timestamp, string Content) : AskAiEvent(Id, Timestamp); /// /// Complete message when streaming is done /// - public sealed record MessageComplete( - string Id, - long Timestamp, - string FullContent - ) : AskAiEvent(Id, Timestamp); + public sealed record MessageComplete(string Id, long Timestamp, string FullContent) : AskAiEvent(Id, Timestamp); /// /// AI is calling the search tool with a specific query /// - public sealed record SearchToolCall( - string Id, - long Timestamp, - string ToolCallId, - string SearchQuery - ) : AskAiEvent(Id, Timestamp); + public sealed record SearchToolCall(string Id, long Timestamp, string ToolCallId, string SearchQuery) : AskAiEvent(Id, Timestamp); /// /// AI is calling a tool (generic fallback for unknown tools) /// - public sealed record ToolCall( - string Id, - long Timestamp, - string ToolCallId, - string ToolName, - string Arguments - ) : AskAiEvent(Id, Timestamp); + public sealed record ToolCall(string Id, long Timestamp, string ToolCallId, string ToolName, string Arguments) : AskAiEvent( + Id, + Timestamp + ); /// /// Result from tool execution /// - public sealed record ToolResult( - string Id, - long Timestamp, - string ToolCallId, - string Result - ) : AskAiEvent(Id, Timestamp); + public sealed record ToolResult(string Id, long Timestamp, string ToolCallId, string Result) : AskAiEvent(Id, Timestamp); /// /// AI is reasoning/thinking (e.g., searching, planning) /// - public sealed record Reasoning( - string Id, - long Timestamp, - string? Message - ) : AskAiEvent(Id, Timestamp); + public sealed record Reasoning(string Id, long Timestamp, string? Message) : AskAiEvent(Id, Timestamp); /// /// Conversation has ended /// - public sealed record ConversationEnd( - string Id, - long Timestamp - ) : AskAiEvent(Id, Timestamp); + public sealed record ConversationEnd(string Id, long Timestamp) : AskAiEvent(Id, Timestamp); /// /// An error occurred /// - public sealed record ErrorEvent( - string Id, - long Timestamp, - string Message - ) : AskAiEvent(Id, Timestamp); + public sealed record ErrorEvent(string Id, long Timestamp, string Message) : AskAiEvent(Id, Timestamp); } /// diff --git a/src/api/Elastic.Documentation.Api/AskAi/AskAiMessageFeedbackRequest.cs b/src/api/Elastic.Documentation.Api/AskAi/AskAiMessageFeedbackRequest.cs index 1ab6752d41..e3030adc51 100644 --- a/src/api/Elastic.Documentation.Api/AskAi/AskAiMessageFeedbackRequest.cs +++ b/src/api/Elastic.Documentation.Api/AskAi/AskAiMessageFeedbackRequest.cs @@ -10,11 +10,7 @@ namespace Elastic.Documentation.Api.AskAi; /// Request model for submitting feedback on a specific Ask AI message. /// Using Guid type ensures automatic validation during JSON deserialization. /// -public record AskAiMessageFeedbackRequest( - Guid MessageId, - Guid ConversationId, - Reaction Reaction -); +public record AskAiMessageFeedbackRequest(Guid MessageId, Guid ConversationId, Reaction Reaction); /// /// The user's reaction to an Ask AI message. diff --git a/src/api/Elastic.Documentation.Api/AskAi/IAskAiMessageFeedbackGateway.cs b/src/api/Elastic.Documentation.Api/AskAi/IAskAiMessageFeedbackGateway.cs index 9f3ca07407..070be0179a 100644 --- a/src/api/Elastic.Documentation.Api/AskAi/IAskAiMessageFeedbackGateway.cs +++ b/src/api/Elastic.Documentation.Api/AskAi/IAskAiMessageFeedbackGateway.cs @@ -21,9 +21,4 @@ public interface IAskAiMessageFeedbackService /// /// Internal record used to pass message feedback data to the gateway. /// -public record AskAiMessageFeedbackRecord( - Guid MessageId, - Guid ConversationId, - Reaction Reaction, - string? Euid = null -); +public record AskAiMessageFeedbackRecord(Guid MessageId, Guid ConversationId, Reaction Reaction, string? Euid = null); diff --git a/src/api/Elastic.Documentation.Api/AskAi/IStreamTransformer.cs b/src/api/Elastic.Documentation.Api/AskAi/IStreamTransformer.cs index f1a20eb8bb..39320c9120 100644 --- a/src/api/Elastic.Documentation.Api/AskAi/IStreamTransformer.cs +++ b/src/api/Elastic.Documentation.Api/AskAi/IStreamTransformer.cs @@ -30,5 +30,10 @@ public interface IStreamTransformer /// Parent activity to track the streaming operation (will be disposed when stream completes) /// Cancellation token /// Stream containing SSE-formatted AskAiEvent objects - Task TransformAsync(Stream rawStream, Guid? generatedConversationId, System.Diagnostics.Activity? parentActivity, CancellationToken cancellationToken = default); + Task TransformAsync( + Stream rawStream, + Guid? generatedConversationId, + System.Diagnostics.Activity? parentActivity, + CancellationToken cancellationToken = default + ); } diff --git a/src/api/Elastic.Documentation.Api/Aws/LambdaExtensionParameterProvider.cs b/src/api/Elastic.Documentation.Api/Aws/LambdaExtensionParameterProvider.cs index 80b600ac49..89e1e0e50f 100644 --- a/src/api/Elastic.Documentation.Api/Aws/LambdaExtensionParameterProvider.cs +++ b/src/api/Elastic.Documentation.Api/Aws/LambdaExtensionParameterProvider.cs @@ -9,7 +9,11 @@ namespace Elastic.Documentation.Api.Aws; -public class LambdaExtensionParameterProvider(IHttpClientFactory httpClientFactory, AppEnvironment appEnvironment, ILogger logger) : IParameterProvider +public class LambdaExtensionParameterProvider( + IHttpClientFactory httpClientFactory, + AppEnvironment appEnvironment, + ILogger logger +) : IParameterProvider { public const string HttpClientName = "AwsParametersAndSecretsLambdaExtensionClient"; private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); @@ -21,7 +25,12 @@ public async Task GetParam(string name, bool withDecryption = true, Canc var prefix = $"/elastic-docs-v3/{appEnvironment.Current.ToStringFast(true)}/"; var prefixedName = prefix + name.TrimStart('/'); logger.LogInformation("Retrieving parameter '{Name}' from Lambda Extension (SSM Parameter Store).", prefixedName); - var response = await _httpClient.GetFromJsonAsync($"/systemsmanager/parameters/get?name={Uri.EscapeDataString(prefixedName)}&withDecryption={withDecryption.ToString().ToLowerInvariant()}", AwsJsonContext.Default.ParameterResponse, ctx); + var response = + await _httpClient.GetFromJsonAsync( + $"/systemsmanager/parameters/get?name={Uri.EscapeDataString(prefixedName)}&withDecryption={withDecryption.ToString().ToLowerInvariant()}", + AwsJsonContext.Default.ParameterResponse, + ctx + ); return response?.Parameter?.Value ?? throw new InvalidOperationException($"Parameter value for '{name}' is null."); } catch (HttpRequestException httpEx) @@ -60,7 +69,6 @@ internal sealed class Parameter public required string DataType { get; set; } } - [JsonSerializable(typeof(ParameterResponse))] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.Unspecified)] internal sealed partial class AwsJsonContext : JsonSerializerContext; diff --git a/src/api/Elastic.Documentation.Api/Caching/DynamoDbDistributedCache.cs b/src/api/Elastic.Documentation.Api/Caching/DynamoDbDistributedCache.cs index f3f3ba940a..0dd9e04d44 100644 --- a/src/api/Elastic.Documentation.Api/Caching/DynamoDbDistributedCache.cs +++ b/src/api/Elastic.Documentation.Api/Caching/DynamoDbDistributedCache.cs @@ -16,7 +16,11 @@ namespace Elastic.Documentation.Api.Caching; /// Provides distributed caching across all Lambda containers using DynamoDB as backing store. /// Clean Code: Constructor injection (Dependency Inversion), small focused methods. /// -public sealed class DynamoDbDistributedCache(IAmazonDynamoDB dynamoDb, string tableName, ILogger logger) : IDistributedCache +public sealed class DynamoDbDistributedCache( + IAmazonDynamoDB dynamoDb, + string tableName, + ILogger logger +) : IDistributedCache { private static readonly ActivitySource ActivitySource = new(TelemetryConstants.CacheSourceName); @@ -35,14 +39,15 @@ public sealed class DynamoDbDistributedCache(IAmazonDynamoDB dynamoDb, string ta try { - var response = await dynamoDb.GetItemAsync(new GetItemRequest - { - TableName = tableName, - Key = new Dictionary - { - [AttributeCacheKey] = new() { S = hashedKey } - } - }, ct); + var response = + await dynamoDb.GetItemAsync( + new GetItemRequest + { + TableName = tableName, + Key = new Dictionary { [AttributeCacheKey] = new() { S = hashedKey } } + }, + ct + ); if (!response.IsItemSet) { @@ -53,9 +58,7 @@ public sealed class DynamoDbDistributedCache(IAmazonDynamoDB dynamoDb, string ta // DynamoDB TTL handles expiration automatically // Items may still be returned briefly after expiration until DynamoDB deletes them - var value = response.Item.TryGetValue(AttributeValue, out var valueAttr) - ? valueAttr.S - : null; + var value = response.Item.TryGetValue(AttributeValue, out var valueAttr) ? valueAttr.S : null; _ = (activity?.SetTag("cache.hit", value != null)); if (value != null) @@ -108,16 +111,20 @@ public async Task SetAsync(CacheKey key, string value, TimeSpan ttl, Cancel ct = var expiresAt = DateTimeOffset.UtcNow.Add(ttl); var ttlTimestamp = expiresAt.ToUnixTimeSeconds(); - _ = await dynamoDb.PutItemAsync(new PutItemRequest - { - TableName = tableName, - Item = new Dictionary - { - [AttributeCacheKey] = new() { S = hashedKey }, - [AttributeValue] = new() { S = value }, - [AttributeTtl] = new() { N = ttlTimestamp.ToString(CultureInfo.InvariantCulture) } - } - }, ct); + _ = + await dynamoDb.PutItemAsync( + new PutItemRequest + { + TableName = tableName, + Item = new Dictionary + { + [AttributeCacheKey] = new() { S = hashedKey }, + [AttributeValue] = new() { S = value }, + [AttributeTtl] = new() { N = ttlTimestamp.ToString(CultureInfo.InvariantCulture) } + } + }, + ct + ); logger.LogDebug("Cache set for key: {CacheKey}, TTL: {TTL}s", hashedKey, ttl.TotalSeconds); } @@ -131,7 +138,12 @@ public async Task SetAsync(CacheKey key, string value, TimeSpan ttl, Cancel ct = catch (ProvisionedThroughputExceededException ex) { _ = activity?.SetTag("cache.error", "provisioned_throughput_exceeded"); - logger.LogWarning(ex, "Provisioned throughput exceeded for DynamoDB cache table {TableName}. Unable to cache key {CacheKey}.", tableName, hashedKey); + logger.LogWarning( + ex, + "Provisioned throughput exceeded for DynamoDB cache table {TableName}. Unable to cache key {CacheKey}.", + tableName, + hashedKey + ); } catch (InternalServerErrorException ex) { diff --git a/src/api/Elastic.Documentation.Api/Caching/InMemoryDistributedCache.cs b/src/api/Elastic.Documentation.Api/Caching/InMemoryDistributedCache.cs index 78e714d66d..464ebc7ec4 100644 --- a/src/api/Elastic.Documentation.Api/Caching/InMemoryDistributedCache.cs +++ b/src/api/Elastic.Documentation.Api/Caching/InMemoryDistributedCache.cs @@ -52,6 +52,5 @@ public Task SetAsync(CacheKey key, string value, TimeSpan ttl, Cancel ct = defau /// Checks if a cache entry has expired. /// Clean Code: Single-purpose helper method with intention-revealing name. ///
- private static bool IsExpired(CacheEntry entry) => - entry.ExpiresAt <= DateTimeOffset.UtcNow; + private static bool IsExpired(CacheEntry entry) => entry.ExpiresAt <= DateTimeOffset.UtcNow; } diff --git a/src/api/Elastic.Documentation.Api/Caching/MultiLayerCache.cs b/src/api/Elastic.Documentation.Api/Caching/MultiLayerCache.cs index 0f59aa42cd..55774300d7 100644 --- a/src/api/Elastic.Documentation.Api/Caching/MultiLayerCache.cs +++ b/src/api/Elastic.Documentation.Api/Caching/MultiLayerCache.cs @@ -126,6 +126,5 @@ private static void PopulateL1(string key, string value, TimeSpan ttl) /// Checks if L1 cache entry has expired. /// Clean Code: Single-purpose helper with intention-revealing name. ///
- private static bool IsExpired(L1CacheEntry entry) => - entry.ExpiresAt <= DateTimeOffset.UtcNow; + private static bool IsExpired(L1CacheEntry entry) => entry.ExpiresAt <= DateTimeOffset.UtcNow; } diff --git a/src/api/Elastic.Documentation.Api/Gcp/GcpIdTokenProvider.cs b/src/api/Elastic.Documentation.Api/Gcp/GcpIdTokenProvider.cs index 2a864af938..71d566ae60 100644 --- a/src/api/Elastic.Documentation.Api/Gcp/GcpIdTokenProvider.cs +++ b/src/api/Elastic.Documentation.Api/Gcp/GcpIdTokenProvider.cs @@ -89,7 +89,6 @@ public async Task GenerateIdTokenAsync(string serviceAccount, string tar return idToken; } - private async Task ExchangeJwtForIdToken(string jwt, string targetAudience, Cancel cancellationToken) { var requestContent = new FormUrlEncodedContent([ @@ -117,7 +116,6 @@ private static string Base64UrlEncode(byte[] input) // Convert base64 to base64url encoding return base64.Replace('+', '-').Replace('/', '_').TrimEnd('='); } - } internal readonly record struct ServiceAccountKey( @@ -135,14 +133,7 @@ string ClientX509CertUrl internal readonly record struct JwtHeader(string Alg, string Typ, string Kid); -internal readonly record struct JwtPayload( - string Iss, - string Sub, - string Aud, - long Iat, - long Exp, - string TargetAudience -); +internal readonly record struct JwtPayload(string Iss, string Sub, string Aud, long Iat, long Exp, string TargetAudience); [JsonSerializable(typeof(ServiceAccountKey))] [JsonSerializable(typeof(JwtPayload))] diff --git a/src/api/Elastic.Documentation.Api/MappingsExtensions.cs b/src/api/Elastic.Documentation.Api/MappingsExtensions.cs index 8c7a906551..01c91bd156 100644 --- a/src/api/Elastic.Documentation.Api/MappingsExtensions.cs +++ b/src/api/Elastic.Documentation.Api/MappingsExtensions.cs @@ -29,135 +29,152 @@ public static void MapElasticDocsApiEndpoints(this IEndpointRouteBuilder group) private static void MapAskAiEndpoint(IEndpointRouteBuilder group) { var askAiGroup = group.MapGroup("/ask-ai"); - _ = askAiGroup.MapPost("/stream", async (HttpContext context, AskAiRequest askAiRequest, IAskAiService askAiService, IStreamTransformer streamTransformer, ILogger logger, Cancel ctx) => - { - context.Response.ContentType = "text/event-stream"; - context.Response.Headers.CacheControl = "no-cache"; - context.Response.Headers.Connection = "keep-alive"; - - var askAiActivitySource = new ActivitySource(TelemetryConstants.AskAiSourceName); - logger.LogInformation("Starting AskAI chat with {AgentProvider} and {AgentId}", streamTransformer.AgentProvider, streamTransformer.AgentId); - var activity = askAiActivitySource.StartActivity($"chat {streamTransformer.AgentProvider}", ActivityKind.Client); - _ = activity?.SetTag("gen_ai.operation.name", "chat"); - _ = activity?.SetTag("gen_ai.provider.name", streamTransformer.AgentProvider); - _ = activity?.SetTag("gen_ai.agent.id", streamTransformer.AgentId); - if (askAiRequest.ConversationId is not null) - _ = activity?.SetTag("gen_ai.conversation.id", askAiRequest.ConversationId.ToString()); - - var inputMessages = new[] - { - new InputMessage("user", [new MessagePart("text", askAiRequest.Message)]) - }; - var inputMessagesJson = JsonSerializer.Serialize(inputMessages, ApiJsonContext.Default.InputMessageArray); - _ = activity?.SetTag("gen_ai.input.messages", inputMessagesJson); - var sanitizedMessage = askAiRequest.Message?.Replace("\r", "").Replace("\n", ""); - logger.LogInformation("AskAI input message: <{ask_ai.input.message}>", sanitizedMessage); - logger.LogInformation("Streaming AskAI response"); - - var response = await askAiService.AskAi(askAiRequest, ctx); - - var conversationId = response.GeneratedConversationId ?? askAiRequest.ConversationId; - if (conversationId is not null) - _ = activity?.SetTag("gen_ai.conversation.id", conversationId.ToString()); - - var transformedStream = await streamTransformer.TransformAsync( - response.Stream, - response.GeneratedConversationId, - activity, - ctx); - await transformedStream.CopyToAsync(context.Response.Body, ctx); - }); - - // UUID validation is automatic via Guid type deserialization (returns 400 if invalid) - _ = askAiGroup.MapPost("/message-feedback", async (HttpContext context, AskAiMessageFeedbackRequest request, IAskAiMessageFeedbackService feedbackService, ILogger logger, Cancel ctx) => - { - // Extract euid cookie for user tracking - _ = context.Request.Cookies.TryGetValue("euid", out var euid); - - var feedbackActivitySource = new ActivitySource(TelemetryConstants.AskAiFeedbackSourceName); - using var activity = feedbackActivitySource.StartActivity("record message-feedback", ActivityKind.Internal); - _ = activity?.SetTag("gen_ai.conversation.id", request.ConversationId); - _ = activity?.SetTag("ask_ai.message.id", request.MessageId); - _ = activity?.SetTag("ask_ai.feedback.reaction", request.Reaction.ToString().ToLowerInvariant()); - - logger.LogInformation( - "Recording message feedback for message {MessageId} in conversation {ConversationId}: {Reaction}", - request.MessageId, - request.ConversationId, - request.Reaction); - - var record = new AskAiMessageFeedbackRecord( - request.MessageId, - request.ConversationId, - request.Reaction, - euid + _ = + askAiGroup.MapPost( + "/stream", + async ( + HttpContext context, + AskAiRequest askAiRequest, + IAskAiService askAiService, + IStreamTransformer streamTransformer, + ILogger logger, + Cancel ctx + ) => + { + context.Response.ContentType = "text/event-stream"; + context.Response.Headers.CacheControl = "no-cache"; + context.Response.Headers.Connection = "keep-alive"; + + var askAiActivitySource = new ActivitySource(TelemetryConstants.AskAiSourceName); + logger.LogInformation( + "Starting AskAI chat with {AgentProvider} and {AgentId}", + streamTransformer.AgentProvider, + streamTransformer.AgentId + ); + var activity = askAiActivitySource.StartActivity($"chat {streamTransformer.AgentProvider}", ActivityKind.Client); + _ = activity?.SetTag("gen_ai.operation.name", "chat"); + _ = activity?.SetTag("gen_ai.provider.name", streamTransformer.AgentProvider); + _ = activity?.SetTag("gen_ai.agent.id", streamTransformer.AgentId); + if (askAiRequest.ConversationId is not null) + _ = activity?.SetTag("gen_ai.conversation.id", askAiRequest.ConversationId.ToString()); + + var inputMessages = new[] { new InputMessage("user", [new MessagePart("text", askAiRequest.Message)]) }; + var inputMessagesJson = JsonSerializer.Serialize(inputMessages, ApiJsonContext.Default.InputMessageArray); + _ = activity?.SetTag("gen_ai.input.messages", inputMessagesJson); + var sanitizedMessage = askAiRequest.Message?.Replace("\r", "").Replace("\n", ""); + logger.LogInformation("AskAI input message: <{ask_ai.input.message}>", sanitizedMessage); + logger.LogInformation("Streaming AskAI response"); + + var response = await askAiService.AskAi(askAiRequest, ctx); + + var conversationId = response.GeneratedConversationId ?? askAiRequest.ConversationId; + if (conversationId is not null) + _ = activity?.SetTag("gen_ai.conversation.id", conversationId.ToString()); + + var transformedStream = + await streamTransformer.TransformAsync(response.Stream, response.GeneratedConversationId, activity, ctx); + await transformedStream.CopyToAsync(context.Response.Body, ctx); + } ); - await feedbackService.RecordFeedbackAsync(record, ctx); - return Results.NoContent(); - }).DisableAntiforgery(); + // UUID validation is automatic via Guid type deserialization (returns 400 if invalid) + _ = + askAiGroup.MapPost( + "/message-feedback", + async ( + HttpContext context, + AskAiMessageFeedbackRequest request, + IAskAiMessageFeedbackService feedbackService, + ILogger logger, + Cancel ctx + ) => + { + // Extract euid cookie for user tracking + _ = context.Request.Cookies.TryGetValue("euid", out var euid); + + var feedbackActivitySource = new ActivitySource(TelemetryConstants.AskAiFeedbackSourceName); + using var activity = feedbackActivitySource.StartActivity("record message-feedback", ActivityKind.Internal); + _ = activity?.SetTag("gen_ai.conversation.id", request.ConversationId); + _ = activity?.SetTag("ask_ai.message.id", request.MessageId); + _ = activity?.SetTag("ask_ai.feedback.reaction", request.Reaction.ToString().ToLowerInvariant()); + + logger.LogInformation( + "Recording message feedback for message {MessageId} in conversation {ConversationId}: {Reaction}", + request.MessageId, + request.ConversationId, + request.Reaction + ); + + var record = new AskAiMessageFeedbackRecord(request.MessageId, request.ConversationId, request.Reaction, euid); + + await feedbackService.RecordFeedbackAsync(record, ctx); + return Results.NoContent(); + } + ).DisableAntiforgery(); } private static void MapNavigationSearch(IEndpointRouteBuilder group) { var searchGroup = group.MapGroup("/navigation-search"); - _ = searchGroup.MapGet("/", - async ( - [FromQuery(Name = "q")] string query, - [FromQuery(Name = "page")] int? pageNumber, - [FromQuery(Name = "type")] string? typeFilter, - INavigationSearchService navigationSearchService, - Cancel ctx - ) => - { - var request = new NavigationSearchRequest + _ = + searchGroup.MapGet( + "/", + async ( + [FromQuery(Name = "q")] string query, + [FromQuery(Name = "page")] int? pageNumber, + [FromQuery(Name = "type")] string? typeFilter, + INavigationSearchService navigationSearchService, + Cancel ctx + ) => { - Query = query, - PageNumber = pageNumber ?? 1, - TypeFilter = typeFilter - }; - var response = await navigationSearchService.NavigationSearchAsync(request, ctx); - return Results.Ok(response); - }); + var request = new NavigationSearchRequest { Query = query, PageNumber = pageNumber ?? 1, TypeFilter = typeFilter }; + var response = await navigationSearchService.NavigationSearchAsync(request, ctx); + return Results.Ok(response); + } + ); } private static void MapFullSearch(IEndpointRouteBuilder group) { var searchGroup = group.MapGroup("/search"); - _ = searchGroup.MapGet("/", - async ( - [FromQuery(Name = "q")] string query, - [FromQuery(Name = "page")] int? pageNumber, - [FromQuery(Name = "size")] int? pageSize, - [FromQuery(Name = "type")] string[]? typeFilter, - [FromQuery(Name = "section")] string[]? sectionFilter, - [FromQuery(Name = "deployment")] string[]? deploymentFilter, - [FromQuery(Name = "product")] string[]? productFilter, - [FromQuery(Name = "version")] string? versionFilter, - [FromQuery(Name = "sort")] string? sortBy, - IFullSearchService searchService, - Cancel ctx - ) => - { - var request = new FullSearchRequest + _ = + searchGroup.MapGet( + "/", + async ( + [FromQuery(Name = "q")] string query, + [FromQuery(Name = "page")] int? pageNumber, + [FromQuery(Name = "size")] int? pageSize, + [FromQuery(Name = "type")] string[]? typeFilter, + [FromQuery(Name = "section")] string[]? sectionFilter, + [FromQuery(Name = "deployment")] string[]? deploymentFilter, + [FromQuery(Name = "product")] string[]? productFilter, + [FromQuery(Name = "version")] string? versionFilter, + [FromQuery(Name = "sort")] string? sortBy, + IFullSearchService searchService, + Cancel ctx + ) => { - Query = query, - PageNumber = pageNumber ?? 1, - PageSize = pageSize ?? 20, - TypeFilter = typeFilter, - SectionFilter = sectionFilter, - DeploymentFilter = deploymentFilter, - ProductFilter = productFilter, - VersionFilter = versionFilter, - SortBy = sortBy ?? "relevance" - }; - var response = await searchService.SearchAsync(request, ctx); - return Results.Ok(response); - }); + var request = new FullSearchRequest + { + Query = query, + PageNumber = pageNumber ?? 1, + PageSize = pageSize ?? 20, + TypeFilter = typeFilter, + SectionFilter = sectionFilter, + DeploymentFilter = deploymentFilter, + ProductFilter = productFilter, + VersionFilter = versionFilter, + SortBy = sortBy ?? "relevance" + }; + var response = await searchService.SearchAsync(request, ctx); + return Results.Ok(response); + } + ); } private static void MapChanges(IEndpointRouteBuilder group) => - group.MapGet("/changes", + group.MapGet( + "/changes", async ( [FromQuery(Name = "since")] DateTimeOffset since, [FromQuery(Name = "cursor")] string? cursor, @@ -166,14 +183,9 @@ private static void MapChanges(IEndpointRouteBuilder group) => Cancel ctx ) => { - var request = new ChangesRequest - { - Since = since, - PageSize = pageSize ?? ChangesDefaults.PageSize, - Cursor = cursor - }; + var request = new ChangesRequest { Since = since, PageSize = pageSize ?? ChangesDefaults.PageSize, Cursor = cursor }; var response = await changesService.GetChangesAsync(request, ctx); return Results.Ok(response); - }); - + } + ); } diff --git a/src/api/Elastic.Documentation.Api/OpenTelemetry/OpenTelemetryExtensions.cs b/src/api/Elastic.Documentation.Api/OpenTelemetry/OpenTelemetryExtensions.cs index 755780220b..a87c5c059b 100644 --- a/src/api/Elastic.Documentation.Api/OpenTelemetry/OpenTelemetryExtensions.cs +++ b/src/api/Elastic.Documentation.Api/OpenTelemetry/OpenTelemetryExtensions.cs @@ -14,21 +14,21 @@ public static class OpenTelemetryExtensions ///
public static TracerProviderBuilder AddDocsApiTracing(this TracerProviderBuilder builder) { - _ = builder - .AddSource(TelemetryConstants.AskAiSourceName) - .AddSource(TelemetryConstants.StreamTransformerSourceName) - .AddSource(TelemetryConstants.CacheSourceName) - .AddSource(TelemetryConstants.AskAiFeedbackSourceName) - .AddAspNetCoreInstrumentation(aspNetCoreOptions => - { - // Don't trace root API endpoint (health check) - aspNetCoreOptions.Filter = (httpContext) => + _ = + builder.AddSource(TelemetryConstants.AskAiSourceName) + .AddSource(TelemetryConstants.StreamTransformerSourceName) + .AddSource(TelemetryConstants.CacheSourceName) + .AddSource(TelemetryConstants.AskAiFeedbackSourceName) + .AddAspNetCoreInstrumentation(aspNetCoreOptions => { - var path = httpContext.Request.Path.Value ?? string.Empty; - // Exclude root API path: /docs/_api/v1 - return path != "/docs/_api/v1"; - }; - }); + // Don't trace root API endpoint (health check) + aspNetCoreOptions.Filter = (httpContext) => + { + var path = httpContext.Request.Path.Value ?? string.Empty; + // Exclude root API path: /docs/_api/v1 + return path != "/docs/_api/v1"; + }; + }); return builder; } diff --git a/src/api/Elastic.Documentation.Api/Program.cs b/src/api/Elastic.Documentation.Api/Program.cs index 668f05d38e..f8b03eaff3 100644 --- a/src/api/Elastic.Documentation.Api/Program.cs +++ b/src/api/Elastic.Documentation.Api/Program.cs @@ -22,22 +22,22 @@ { _ = s.AddSingleton(AssemblyConfiguration.Create(p)); }) - .AddDocumentationOpenTelemetry(new OtelRegistration("docs-api") - { - Tracing = (_, t) => t.AddDocsApiTracing(), - }) + .AddDocumentationOpenTelemetry(new OtelRegistration("docs-api") { Tracing = (_, t) => t.AddDocsApiTracing(), }) .HealthCheckBuilderExtensions(); // Only hardcode port 8080 when not running under Aspire/orchestration. // Use builder.Configuration so both ASPNETCORE_* and DOTNET_* prefix variants are covered. - if (string.IsNullOrEmpty(builder.Configuration["HTTP_PORTS"]) + if ( + string.IsNullOrEmpty(builder.Configuration["HTTP_PORTS"]) && string.IsNullOrEmpty(builder.Configuration["HTTPS_PORTS"]) - && string.IsNullOrEmpty(builder.Configuration["URLS"])) + && string.IsNullOrEmpty(builder.Configuration["URLS"]) + ) { - _ = builder.WebHost.ConfigureKestrel(serverOptions => - { - serverOptions.ListenAnyIP(8080); - }); + _ = + builder.WebHost.ConfigureKestrel(serverOptions => + { + serverOptions.ListenAnyIP(8080); + }); } builder.Services.AddElasticDocsApiServices(environment); @@ -54,14 +54,17 @@ _ = app.Environment.IsDevelopment() ? app.UseDeveloperExceptionPage() - : app.UseExceptionHandler(err => err.Run(context => - { - var ex = context.Features.Get()?.Error; - if (ex != null) - logger.LogError(ex, "Unhandled exception on {Method} {Path}", context.Request.Method, context.Request.Path); - context.Response.StatusCode = 500; - return Task.CompletedTask; - })); + : app.UseExceptionHandler( + err => + err.Run(context => + { + var ex = context.Features.Get()?.Error; + if (ex != null) + logger.LogError(ex, "Unhandled exception on {Method} {Path}", context.Request.Method, context.Request.Path); + context.Response.StatusCode = 500; + return Task.CompletedTask; + }) + ); var api = app.MapGroup(SystemEnvironmentVariables.Instance.ApiPrefix); diff --git a/src/api/Elastic.Documentation.Api/SerializationContext.cs b/src/api/Elastic.Documentation.Api/SerializationContext.cs index d5474683b9..92d8278a4f 100644 --- a/src/api/Elastic.Documentation.Api/SerializationContext.cs +++ b/src/api/Elastic.Documentation.Api/SerializationContext.cs @@ -27,11 +27,9 @@ public record OutputMessage(string Role, MessagePart[] Parts, string FinishReaso [JsonSerializable(typeof(OutputMessage[]))] [JsonSerializable(typeof(MessagePart))] [JsonSerializable(typeof(InputMessage[]))] - [JsonSerializable(typeof(FullSearchRequest))] [JsonSerializable(typeof(FullSearchResponse))] [JsonSerializable(typeof(FullSearchAggregations))] - [JsonSerializable(typeof(ChangesResponse))] [JsonSerializable(typeof(ChangedPageDto))] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] diff --git a/src/api/Elastic.Documentation.Api/ServicesExtension.cs b/src/api/Elastic.Documentation.Api/ServicesExtension.cs index ead042603c..72dd95f9b0 100644 --- a/src/api/Elastic.Documentation.Api/ServicesExtension.cs +++ b/src/api/Elastic.Documentation.Api/ServicesExtension.cs @@ -19,10 +19,14 @@ namespace Elastic.Documentation.Api; [EnumExtensions] public enum AppEnv { - [Display(Name = "dev")] Dev, - [Display(Name = "staging")] Staging, - [Display(Name = "edge")] Edge, - [Display(Name = "prod")] Prod + [Display(Name = "dev")] + Dev, + [Display(Name = "staging")] + Staging, + [Display(Name = "edge")] + Edge, + [Display(Name = "prod")] + Prod } public class AppEnvironment @@ -46,26 +50,33 @@ public static void AddElasticDocsApiServices(this IServiceCollection services, s else { var logger = GetLogger(services); - logger?.LogWarning("Unable to parse environment {AppEnvironment} into AppEnvironment. Using default AppEnvironment.Dev", appEnvironment); + logger?.LogWarning( + "Unable to parse environment {AppEnvironment} into AppEnvironment. Using default AppEnvironment.Dev", + appEnvironment + ); AddElasticDocsApiServices(services, AppEnv.Dev); } } - private static void AddElasticDocsApiServices(this IServiceCollection services, AppEnv appEnv) { - _ = services.ConfigureHttpJsonOptions(options => - { - options.SerializerOptions.TypeInfoResolverChain.Insert(0, ApiJsonContext.Default); - }); + _ = + services.ConfigureHttpJsonOptions(options => + { + options.SerializerOptions.TypeInfoResolverChain.Insert(0, ApiJsonContext.Default); + }); // Configure HttpClient for streaming optimization - _ = services.AddHttpClient("StreamingHttpClient", client => - { - // Disable response buffering for streaming - client.DefaultRequestHeaders.Connection.Add("keep-alive"); - client.Timeout = TimeSpan.FromMinutes(10); // Longer timeout for streaming - }); + _ = + services.AddHttpClient( + "StreamingHttpClient", + client => + { + // Disable response buffering for streaming + client.DefaultRequestHeaders.Connection.Add("keep-alive"); + client.Timeout = TimeSpan.FromMinutes(10); // Longer timeout for streaming + } + ); // Register AppEnvironment as a singleton for dependency injection _ = services.AddSingleton(new AppEnvironment { Current = appEnv }); AddDistributedCache(services, appEnv); @@ -101,18 +112,19 @@ private static void AddDistributedCache(IServiceCollection services, AppEnv appE logger?.LogInformation("AmazonDynamoDB client registered"); // Register multi-layer cache (L1: in-memory + L2: DynamoDB) - _ = services.AddSingleton(sp => - { - var dynamoDb = sp.GetRequiredService(); - var tableName = $"docs-api-cache-{appEnv.ToStringFast(true)}"; - var dynamoLogger = sp.GetRequiredService>(); - var multiLogger = sp.GetRequiredService>(); - - var dynamoCache = new DynamoDbDistributedCache(dynamoDb, tableName, dynamoLogger); - var multiLayerCache = new MultiLayerCache(dynamoCache, multiLogger); - logger?.LogInformation("Multi-layer cache registered with DynamoDB table: {TableName}", tableName); - return multiLayerCache; - }); + _ = + services.AddSingleton(sp => + { + var dynamoDb = sp.GetRequiredService(); + var tableName = $"docs-api-cache-{appEnv.ToStringFast(true)}"; + var dynamoLogger = sp.GetRequiredService>(); + var multiLogger = sp.GetRequiredService>(); + + var dynamoCache = new DynamoDbDistributedCache(dynamoDb, tableName, dynamoLogger); + var multiLayerCache = new MultiLayerCache(dynamoCache, multiLogger); + logger?.LogInformation("Multi-layer cache registered with DynamoDB table: {TableName}", tableName); + return multiLayerCache; + }); } catch (Exception ex) { @@ -168,7 +180,9 @@ private static void AddAskAiServices(IServiceCollection services, AppEnv appEnv) // Register factory as interface implementation _ = services.AddScoped(); _ = services.AddScoped(); - logger?.LogInformation("Service and transformer factories registered successfully - provider switchable via X-AI-Provider header"); + logger?.LogInformation( + "Service and transformer factories registered successfully - provider switchable via X-AI-Provider header" + ); // Register message feedback service (singleton for connection reuse) _ = services.AddSingleton(); @@ -190,5 +204,4 @@ private static void AddSearchServices(IServiceCollection services, AppEnv appEnv _ = services.AddSearchServices(); logger?.LogInformation("Full search service registered with hybrid RRF support"); } - } diff --git a/src/api/Elastic.Documentation.Mcp.Remote/Gateways/DocumentGateway.cs b/src/api/Elastic.Documentation.Mcp.Remote/Gateways/DocumentGateway.cs index e55995f2c0..92326e26ba 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/Gateways/DocumentGateway.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/Gateways/DocumentGateway.cs @@ -14,10 +14,7 @@ namespace Elastic.Documentation.Mcp.Remote.Gateways; /// Gateway implementation for document-specific operations. /// Uses Elasticsearch to fetch documents by URL. ///
-public class DocumentGateway( - ElasticsearchClientAccessor clientAccessor, - ILogger logger) - : IDocumentGateway +public class DocumentGateway(ElasticsearchClientAccessor clientAccessor, ILogger logger) : IDocumentGateway { /// public async Task GetByUrlAsync(string url, CancellationToken ct = default) @@ -27,31 +24,40 @@ public class DocumentGateway( var normalizedUrl = NormalizeUrl(url); // TODO: conditionally omit Body from the source filter when the caller doesn't need it — // currently Body is always fetched even when includeBody=false, wasting network + deserialization. - var response = await clientAccessor.Client.SearchAsync(s => s - .Indices(clientAccessor.SearchIndex) - .Query(q => q.Term(t => t.Field(f => f.Path).Value(normalizedUrl))) - .Size(1) - .Source(sf => sf.Filter(f => f.Includes( - e => e.Path, - e => e.Title, - e => e.SearchTitle, - e => e.Type, - e => e.Description, - e => e.Section, - e => e.Body, - e => e.Parents, - e => e.Headings, - e => e.Links, - e => e.AiShortSummary, - e => e.AiRagOptimizedSummary, - e => e.AiQuestions, - e => e.AiUseCases, - e => e.LastUpdated, - e => e.SourceUrl, - e => e.Product, - e => e.RelatedProducts - ))), - ct); + var response = + await clientAccessor.Client.SearchAsync( + s => + s.Indices(clientAccessor.SearchIndex) + .Query(q => q.Term(t => t.Field(f => f.Path).Value(normalizedUrl))) + .Size(1) + .Source( + sf => + sf.Filter( + f => + f.Includes( + e => e.Path, + e => e.Title, + e => e.SearchTitle, + e => e.Type, + e => e.Description, + e => e.Section, + e => e.Body, + e => e.Parents, + e => e.Headings, + e => e.Links, + e => e.AiShortSummary, + e => e.AiRagOptimizedSummary, + e => e.AiQuestions, + e => e.AiUseCases, + e => e.LastUpdated, + e => e.SourceUrl, + e => e.Product, + e => e.RelatedProducts + ) + ) + ), + ct + ); if (!response.IsValidResponse || response.Documents.Count == 0) { @@ -68,11 +74,7 @@ public class DocumentGateway( Description = doc.Description, NavigationSection = doc.Section, Body = doc.Body, - Parents = doc.Parents.Select(p => new DocumentParent - { - Title = p.Title, - Url = p.Path - }).ToArray(), + Parents = doc.Parents.Select(p => new DocumentParent { Title = p.Title, Url = p.Path }).ToArray(), Headings = doc.Headings, Links = doc.Links ?? [], AiShortSummary = doc.AiShortSummary, @@ -81,18 +83,11 @@ public class DocumentGateway( AiUseCases = doc.AiUseCases, LastUpdated = doc.LastUpdated, SourceUrl = doc.SourceUrl, - Product = doc.Product is { } productId ? new DocumentProduct - { - Id = productId, - Repository = null - } : null, - RelatedProducts = doc.RelatedProducts? - .Where(p => p.Id != null) - .Select(p => new DocumentProduct - { - Id = p.Id!, - Repository = p.Repository - }).ToArray() + Product = doc.Product is { } productId ? new DocumentProduct { Id = productId, Repository = null } : null, + RelatedProducts = + doc.RelatedProducts?.Where(p => p.Id != null).Select( + p => new DocumentProduct { Id = p.Id!, Repository = p.Repository } + ).ToArray() }; } catch (Exception ex) @@ -108,25 +103,34 @@ public class DocumentGateway( try { var normalizedUrl = NormalizeUrl(url); - var response = await clientAccessor.Client.SearchAsync(s => s - .Indices(clientAccessor.SearchIndex) - .Query(q => q.Term(t => t.Field(f => f.Path).Value(normalizedUrl))) - .Size(1) - // Body is fetched solely to compute BodyLength — no stored length field exists in the index. - .Source(sf => sf.Filter(f => f.Includes( - e => e.Path, - e => e.Title, - e => e.SearchTitle, - e => e.Type, - e => e.Parents, - e => e.Headings, - e => e.Links, - e => e.Body, - e => e.AiShortSummary, - e => e.AiQuestions, - e => e.AiUseCases - ))), - ct); + var response = + await clientAccessor.Client.SearchAsync( + s => + s.Indices(clientAccessor.SearchIndex) + .Query(q => q.Term(t => t.Field(f => f.Path).Value(normalizedUrl))) + .Size(1) + // Body is fetched solely to compute BodyLength — no stored length field exists in the index. + .Source( + sf => + sf.Filter( + f => + f.Includes( + e => e.Path, + e => e.Title, + e => e.SearchTitle, + e => e.Type, + e => e.Parents, + e => e.Headings, + e => e.Links, + e => e.Body, + e => e.AiShortSummary, + e => e.AiQuestions, + e => e.AiUseCases + ) + ) + ), + ct + ); if (!response.IsValidResponse || response.Documents.Count == 0) { @@ -144,11 +148,7 @@ public class DocumentGateway( ParentCount = doc.Parents.Length, BodyLength = doc.Body?.Length ?? 0, Headings = doc.Headings, - Parents = doc.Parents.Select(p => new DocumentParent - { - Title = p.Title, - Url = p.Path - }).ToArray(), + Parents = doc.Parents.Select(p => new DocumentParent { Title = p.Title, Url = p.Path }).ToArray(), HasAiSummary = !string.IsNullOrEmpty(doc.AiShortSummary), HasAiQuestions = doc.AiQuestions is { Length: > 0 }, HasAiUseCases = doc.AiUseCases is { Length: > 0 } diff --git a/src/api/Elastic.Documentation.Mcp.Remote/McpBearerAuthMiddleware.cs b/src/api/Elastic.Documentation.Mcp.Remote/McpBearerAuthMiddleware.cs index f90457a61e..4ab99d131a 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/McpBearerAuthMiddleware.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/McpBearerAuthMiddleware.cs @@ -45,9 +45,11 @@ public async Task InvokeAsync(HttpContext context) } var pathValue = path.Value ?? ""; - if (pathValue.Contains("/.well-known", StringComparison.Ordinal) || - pathValue.EndsWith("/health", StringComparison.Ordinal) || - pathValue.EndsWith("/alive", StringComparison.Ordinal)) + if ( + pathValue.Contains("/.well-known", StringComparison.Ordinal) + || pathValue.EndsWith("/health", StringComparison.Ordinal) + || pathValue.EndsWith("/alive", StringComparison.Ordinal) + ) { await next(context); return; @@ -183,7 +185,10 @@ private static async Task WriteUnauthorizedAsync(HttpContext context, IEnvironme return (null, 401); } - var allowedDomains = env.McpAllowedEmailDomains.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var allowedDomains = env.McpAllowedEmailDomains.Split( + ',', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries + ); var domainAllowed = allowedDomains.Length == 0 || allowedDomains.Any(d => sub.EndsWith("@" + d.TrimStart('@'), StringComparison.OrdinalIgnoreCase)); if (!domainAllowed) @@ -201,14 +206,25 @@ private static async Task WriteUnauthorizedAsync(HttpContext context, IEnvironme } catch (SecurityTokenInvalidSignatureException ex) { - logger.LogWarning("MCP auth validation failed: signature_invalid (kid={Kid}, jti={Jti}, iss={Iss}, aud={Aud}, err={Err})", - jwt.Header.Kid, jwt.Payload.Jti, jwt.Issuer, jwt.Audiences?.FirstOrDefault(), ex.Message); + logger.LogWarning( + "MCP auth validation failed: signature_invalid (kid={Kid}, jti={Jti}, iss={Iss}, aud={Aud}, err={Err})", + jwt.Header.Kid, + jwt.Payload.Jti, + jwt.Issuer, + jwt.Audiences?.FirstOrDefault(), + ex.Message + ); return (null, 401); } catch (SecurityTokenException ex) { - logger.LogWarning("MCP auth validation failed: {Type} (kid={Kid}, jti={Jti}, err={Err})", - ex.GetType().Name, jwt.Header.Kid, jwt.Payload.Jti, ex.Message); + logger.LogWarning( + "MCP auth validation failed: {Type} (kid={Kid}, jti={Jti}, err={Err})", + ex.GetType().Name, + jwt.Header.Kid, + jwt.Payload.Jti, + ex.Message + ); return (null, 401); } } @@ -231,6 +247,5 @@ private static RsaSecurityKey GetOrCreateSigningKey(string publicKeyPem, string? } } - private void LogValidationFailure(string reason) => - logger.LogWarning("MCP auth validation failed: {Reason}", reason); + private void LogValidationFailure(string reason) => logger.LogWarning("MCP auth validation failed: {Reason}", reason); } diff --git a/src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs b/src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs index a80ca65fc7..f057b32070 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/McpFeatureModule.cs @@ -25,9 +25,9 @@ public sealed record McpFeatureModule( string? Capability, string[] WhenToUse, string[] ToolGuidance, - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.PublicConstructors)] - [property: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.PublicConstructors)] - Type? ToolType, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.PublicConstructors)][property: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.PublicConstructors)] Type? ToolType, Action RegisterServices ); @@ -36,13 +36,8 @@ internal static class McpFeatureModules public static readonly McpFeatureModule Search = new( Name: "Search", Capability: "search", - WhenToUse: - [ - "Wants to find, read, or verify {docs} pages.", - "Needs to check whether a topic is already covered in {docs}." - ], - ToolGuidance: - [ + WhenToUse: ["Wants to find, read, or verify {docs} pages.", "Needs to check whether a topic is already covered in {docs}."], + ToolGuidance: [ "Prefer {tool:search_{resource}} over a general web search when looking up Elastic documentation content.", "Use {tool:find_related_{resource}} when exploring what documentation exists around a topic." ], @@ -54,8 +49,7 @@ internal static class McpFeatureModules Name: "Documents", Capability: "retrieve", WhenToUse: [], - ToolGuidance: - [ + ToolGuidance: [ "Use {tool:get_{scope}document_by_url} to retrieve a specific page when the user provides or you already know the URL." ], ToolType: typeof(DocumentTools), @@ -65,12 +59,8 @@ internal static class McpFeatureModules public static readonly McpFeatureModule Coherence = new( Name: "Coherence", Capability: "analyze", - WhenToUse: - [ - "Asks about {docs} structure, coherence, or inconsistencies across pages." - ], - ToolGuidance: - [ + WhenToUse: ["Asks about {docs} structure, coherence, or inconsistencies across pages."], + ToolGuidance: [ "Use {tool:check_{resource}_coherence} or {tool:find_{resource}_inconsistencies} when reviewing or auditing documentation quality." ], ToolType: typeof(CoherenceTools), diff --git a/src/api/Elastic.Documentation.Mcp.Remote/McpOAuthMetadata.cs b/src/api/Elastic.Documentation.Mcp.Remote/McpOAuthMetadata.cs index 74130afdcd..9703c0fc16 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/McpOAuthMetadata.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/McpOAuthMetadata.cs @@ -38,49 +38,61 @@ public static void MapEndpoints(RouteGroupBuilder group) var issuer = env.McpOAuthIssuer!; var jwksJson = BuildJwksJson(env); - _ = group.MapGet("/.well-known/oauth-protected-resource", (HttpContext context) => - { - context.Response.Headers.CacheControl = CacheControlValue; - return Results.Json( - new ProtectedResourceMetadata + _ = + group.MapGet( + "/.well-known/oauth-protected-resource", + (HttpContext context) => { - Resource = issuer, - AuthorizationServers = [issuer], - ScopesSupported = ScopesSupported, - BearerMethodsSupported = BearerMethodsSupported - }, - OAuthMetadataJsonContext.Default.ProtectedResourceMetadata + context.Response.Headers.CacheControl = CacheControlValue; + return Results.Json( + new ProtectedResourceMetadata + { + Resource = issuer, + AuthorizationServers = [issuer], + ScopesSupported = ScopesSupported, + BearerMethodsSupported = BearerMethodsSupported + }, + OAuthMetadataJsonContext.Default.ProtectedResourceMetadata + ); + } ); - }); - _ = group.MapGet("/.well-known/openid-configuration", (HttpContext context) => - { - context.Response.Headers.CacheControl = CacheControlValue; - return Results.Json( - new AuthorizationServerMetadata + _ = + group.MapGet( + "/.well-known/openid-configuration", + (HttpContext context) => { - Issuer = issuer, - AuthorizationEndpoint = $"{issuer}/authorize", - TokenEndpoint = $"{issuer}/token", - RegistrationEndpoint = $"{issuer}/register", - JwksUri = $"{issuer}/jwks", - ResponseTypesSupported = ResponseTypesSupported, - GrantTypesSupported = GrantTypesSupported, - CodeChallengeMethodsSupported = CodeChallengeMethodsSupported, - TokenEndpointAuthMethodsSupported = TokenEndpointAuthMethodsSupported, - ScopesSupported = ScopesSupported, - SubjectTypesSupported = SubjectTypesSupported, - IdTokenSigningAlgValuesSupported = IdTokenSigningAlgValuesSupported - }, - OAuthMetadataJsonContext.Default.AuthorizationServerMetadata + context.Response.Headers.CacheControl = CacheControlValue; + return Results.Json( + new AuthorizationServerMetadata + { + Issuer = issuer, + AuthorizationEndpoint = $"{issuer}/authorize", + TokenEndpoint = $"{issuer}/token", + RegistrationEndpoint = $"{issuer}/register", + JwksUri = $"{issuer}/jwks", + ResponseTypesSupported = ResponseTypesSupported, + GrantTypesSupported = GrantTypesSupported, + CodeChallengeMethodsSupported = CodeChallengeMethodsSupported, + TokenEndpointAuthMethodsSupported = TokenEndpointAuthMethodsSupported, + ScopesSupported = ScopesSupported, + SubjectTypesSupported = SubjectTypesSupported, + IdTokenSigningAlgValuesSupported = IdTokenSigningAlgValuesSupported + }, + OAuthMetadataJsonContext.Default.AuthorizationServerMetadata + ); + } ); - }); - _ = group.MapGet("/jwks", (HttpContext context) => - { - context.Response.Headers.CacheControl = CacheControlValue; - return Results.Text(jwksJson, "application/json"); - }); + _ = + group.MapGet( + "/jwks", + (HttpContext context) => + { + context.Response.Headers.CacheControl = CacheControlValue; + return Results.Text(jwksJson, "application/json"); + } + ); } private static string BuildJwksJson(IEnvironmentVariables env) @@ -105,11 +117,7 @@ private static string BuildJwksJson(IEnvironmentVariables env) return System.Text.Json.JsonSerializer.Serialize(new JwksDocument { Keys = [jwk] }, OAuthMetadataJsonContext.Default.JwksDocument); } - private static string Base64UrlEncode(byte[] data) => - Convert.ToBase64String(data) - .TrimEnd('=') - .Replace('+', '-') - .Replace('/', '_'); + private static string Base64UrlEncode(byte[] data) => Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_'); } /// RFC 9728 Protected Resource Metadata. diff --git a/src/api/Elastic.Documentation.Mcp.Remote/McpServerProfile.cs b/src/api/Elastic.Documentation.Mcp.Remote/McpServerProfile.cs index 276491a1ae..c87a537405 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/McpServerProfile.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/McpServerProfile.cs @@ -28,7 +28,8 @@ public sealed record McpServerProfile( string DocsDescription, string Introduction, string[] ExtraTriggers, - McpFeatureModule[] Modules) + McpFeatureModule[] Modules +) { public static McpServerProfile Public { get; } = new( "public", @@ -37,7 +38,9 @@ public sealed record McpServerProfile( "", "Elastic documentation", "Use this server to {capabilities} Elastic product documentation published at elastic.co/docs.", - ["References Elastic product names such as Elasticsearch, Kibana, Fleet, APM, Logstash, Beats, Elastic Security, Elastic Observability, or Elastic Cloud."], + [ + "References Elastic product names such as Elasticsearch, Kibana, Fleet, APM, Logstash, Beats, Elastic Security, Elastic Observability, or Elastic Cloud." + ], [McpFeatureModules.Search, McpFeatureModules.Documents, McpFeatureModules.Coherence] ); @@ -87,20 +90,16 @@ public string ComposeServerInstructions() var capabilities = DeriveCapabilities(); var introduction = Introduction.Replace("{capabilities}", capabilities, StringComparison.Ordinal); - var whenToUse = Modules - .SelectMany(m => m.WhenToUse) + var whenToUse = Modules.SelectMany(m => m.WhenToUse) .Distinct() .Select(line => line.Replace("{docs}", DocsDescription, StringComparison.Ordinal)) .Concat(ExtraTriggers) .ToList(); - var toolGuidance = Modules - .SelectMany(m => m.ToolGuidance) + var toolGuidance = Modules.SelectMany(m => m.ToolGuidance) .Select(line => ReplaceToolPlaceholders(line, ResourceNoun, ScopePrefix)) .ToList(); - var whenToUseBlock = whenToUse.Count > 0 - ? "\n" + string.Join("\n", whenToUse.Select(b => $"- {b}")) - : ""; + var whenToUseBlock = whenToUse.Count > 0 ? "\n" + string.Join("\n", whenToUse.Select(b => $"- {b}")) : ""; var toolGuidanceBlock = toolGuidance.Count > 0 ? "\n\n" + string.Join("\n", toolGuidance.Select(l => $"- {l}")) + "\n" : ""; @@ -137,9 +136,11 @@ private static string ReplaceToolPlaceholders(string line, string resourceNoun, break; end--; var template = line[templateStart..end]; - var resolved = template - .Replace("{resource}", resourceNoun, StringComparison.Ordinal) - .Replace("{scope}", scopePrefix, StringComparison.Ordinal); + var resolved = template.Replace("{resource}", resourceNoun, StringComparison.Ordinal).Replace( + "{scope}", + scopePrefix, + StringComparison.Ordinal + ); _ = sb.Append(line, pos, start - pos); _ = sb.Append(resolved); pos = end + 1; @@ -150,11 +151,7 @@ private static string ReplaceToolPlaceholders(string line, string resourceNoun, private string DeriveCapabilities() { - var verbs = Modules - .Select(m => m.Capability) - .Where(c => !string.IsNullOrEmpty(c)) - .Distinct() - .ToList(); + var verbs = Modules.Select(m => m.Capability).Where(c => !string.IsNullOrEmpty(c)).Distinct().ToList(); return verbs.Count switch { diff --git a/src/api/Elastic.Documentation.Mcp.Remote/McpToolRegistration.cs b/src/api/Elastic.Documentation.Mcp.Remote/McpToolRegistration.cs index 0047cb7fea..851ad67530 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/McpToolRegistration.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/McpToolRegistration.cs @@ -36,8 +36,10 @@ public static IEnumerable CreatePrefixedTools(McpServerProfile pr foreach (var method in methods) { - var nameAttr = method.GetCustomAttribute() - ?? throw new InvalidOperationException($"Method {method.DeclaringType?.Name}.{method.Name} must have [McpToolName] attribute."); + var nameAttr = method.GetCustomAttribute() ?? + throw new InvalidOperationException( + $"Method {method.DeclaringType?.Name}.{method.Name} must have [McpToolName] attribute." + ); var toolName = nameAttr.Template .Replace("{resource}", resourceNoun, StringComparison.Ordinal) .Replace("{scope}", scopePrefix, StringComparison.Ordinal); @@ -45,16 +47,16 @@ public static IEnumerable CreatePrefixedTools(McpServerProfile pr var descAttr = method.GetCustomAttribute(); var description = descAttr?.Description?.Replace("{docs}", docsDescription, StringComparison.Ordinal); - var options = new McpServerToolCreateOptions - { - Name = toolName, - Description = description - }; + var options = new McpServerToolCreateOptions { Name = toolName, Description = description }; var tool = McpServerTool.Create( method, - ctx => (ctx.Services ?? throw new InvalidOperationException("RequestContext.Services is null")).GetRequiredService(module.ToolType), - options); + ctx => + (ctx.Services ?? throw new InvalidOperationException("RequestContext.Services is null")).GetRequiredService( + module.ToolType + ), + options + ); tools.Add(tool); } diff --git a/src/api/Elastic.Documentation.Mcp.Remote/Program.cs b/src/api/Elastic.Documentation.Mcp.Remote/Program.cs index 6622844f2e..4c059af59d 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/Program.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/Program.cs @@ -24,25 +24,24 @@ .HealthCheckBuilderExtensions() .AddDocumentationOpenTelemetry(new OtelRegistration(profile.ServiceName) { - Tracing = (_, t) => t - .WithElasticDefaults() - .AddSource(McpToolTelemetry.McpToolSourceName) - .AddProcessor(new McpSpanRenameProcessor()), - Metrics = (_, m) => m - .WithElasticDefaults() - .AddMeter(McpToolTelemetry.McpMeterName) + Tracing = + (_, t) => t.WithElasticDefaults().AddSource(McpToolTelemetry.McpToolSourceName).AddProcessor(new McpSpanRenameProcessor()), + Metrics = (_, m) => m.WithElasticDefaults().AddMeter(McpToolTelemetry.McpMeterName) }); // Only hardcode port 8080 when not running under Aspire/orchestration. // Use builder.Configuration so both ASPNETCORE_* and DOTNET_* prefix variants are covered. - if (string.IsNullOrEmpty(builder.Configuration["HTTP_PORTS"]) + if ( + string.IsNullOrEmpty(builder.Configuration["HTTP_PORTS"]) && string.IsNullOrEmpty(builder.Configuration["HTTPS_PORTS"]) - && string.IsNullOrEmpty(builder.Configuration["URLS"])) + && string.IsNullOrEmpty(builder.Configuration["URLS"]) + ) { - _ = builder.WebHost.ConfigureKestrel(serverOptions => - { - serverOptions.ListenAnyIP(8080); - }); + _ = + builder.WebHost.ConfigureKestrel(serverOptions => + { + serverOptions.ListenAnyIP(8080); + }); } var environment = Environment.GetEnvironmentVariable("ENVIRONMENT"); @@ -52,10 +51,11 @@ // CreateSlimBuilder disables reflection-based JSON serialization. // McpJsonUtilities registers System.String so the SDK's error responses can serialize. - _ = builder.Services.ConfigureHttpJsonOptions(options => - { - options.SerializerOptions.TypeInfoResolverChain.Insert(0, McpJsonUtilities.DefaultOptions.TypeInfoResolver!); - }); + _ = + builder.Services.ConfigureHttpJsonOptions(options => + { + options.SerializerOptions.TypeInfoResolverChain.Insert(0, McpJsonUtilities.DefaultOptions.TypeInfoResolver!); + }); // Stateless Streamable HTTP transport: each request is an independent POST / — no session // affinity, no Mcp-Session-Id header, no server-initiated push (sampling/elicitation/roots). @@ -83,14 +83,17 @@ _ = app.Environment.IsDevelopment() ? app.UseDeveloperExceptionPage() - : app.UseExceptionHandler(err => err.Run(context => - { - var ex = context.Features.Get()?.Error; - if (ex != null) - logger.LogError(ex, "Unhandled exception on {Method} {Path}", context.Request.Method, context.Request.Path); - context.Response.StatusCode = 500; - return Task.CompletedTask; - })); + : app.UseExceptionHandler( + err => + err.Run(context => + { + var ex = context.Features.Get()?.Error; + if (ex != null) + logger.LogError(ex, "Unhandled exception on {Method} {Path}", context.Request.Method, context.Request.Path); + context.Response.StatusCode = 500; + return Task.CompletedTask; + }) + ); _ = app.UseMiddleware(); diff --git a/src/api/Elastic.Documentation.Mcp.Remote/Telemetry/McpSpanRenameProcessor.cs b/src/api/Elastic.Documentation.Mcp.Remote/Telemetry/McpSpanRenameProcessor.cs index 76c067125a..d9cdc9d048 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/Telemetry/McpSpanRenameProcessor.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/Telemetry/McpSpanRenameProcessor.cs @@ -22,8 +22,7 @@ public override void OnEnd(Activity activity) if (activity.Kind != ActivityKind.Server) return; - if (activity.GetTagItem("mcp.method.name") is string methodName - && activity.GetTagItem("mcp.tool.name") is string toolName) + if (activity.GetTagItem("mcp.method.name") is string methodName && activity.GetTagItem("mcp.tool.name") is string toolName) activity.DisplayName = $"{methodName} {toolName}"; } } diff --git a/src/api/Elastic.Documentation.Mcp.Remote/Telemetry/McpToolTelemetry.cs b/src/api/Elastic.Documentation.Mcp.Remote/Telemetry/McpToolTelemetry.cs index f54ecc138f..4861b13b33 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/Telemetry/McpToolTelemetry.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/Telemetry/McpToolTelemetry.cs @@ -17,10 +17,16 @@ public static class McpToolTelemetry private static readonly ActivitySource McpActivitySource = new(McpToolSourceName); private static readonly Meter McpMeter = new(McpMeterName); - private static readonly Counter ToolCallsCounter = - McpMeter.CreateCounter("mcp.tool.calls", unit: "{call}", description: "Number of MCP tool calls"); - private static readonly Histogram ToolDurationHistogram = - McpMeter.CreateHistogram("mcp.tool.duration", unit: "s", description: "Duration of MCP tool calls in seconds"); + private static readonly Counter ToolCallsCounter = McpMeter.CreateCounter( + "mcp.tool.calls", + unit: "{call}", + description: "Number of MCP tool calls" + ); + private static readonly Histogram ToolDurationHistogram = McpMeter.CreateHistogram( + "mcp.tool.duration", + unit: "s", + description: "Duration of MCP tool calls in seconds" + ); private static readonly McpServerProfile ServerProfile = ResolveServerProfile(); private static readonly string? ServerVersion = ResolveServerVersion(); @@ -28,9 +34,11 @@ public static class McpToolTelemetry private const string McpMethodToolsCall = "tools/call"; public static string ResolveToolName(string template) => - template - .Replace("{resource}", ServerProfile.ResourceNoun, StringComparison.Ordinal) - .Replace("{scope}", ServerProfile.ScopePrefix, StringComparison.Ordinal); + template.Replace("{resource}", ServerProfile.ResourceNoun, StringComparison.Ordinal).Replace( + "{scope}", + ServerProfile.ScopePrefix, + StringComparison.Ordinal + ); public static Activity? StartActivity(string toolName) { @@ -78,9 +86,7 @@ private static void EnrichServerActivity(string toolName) public static PayloadMetadata SetPayloadMetadata(Activity? activity, IReadOnlyDictionary arguments) { - var argumentKeys = arguments.Keys - .OrderBy(k => k, StringComparer.Ordinal) - .ToArray(); + var argumentKeys = arguments.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray(); var argKeys = string.Join(",", argumentKeys); _ = activity?.SetTag("mcp.payload.arg_count", argumentKeys.Length); @@ -130,7 +136,8 @@ public static void LogStart(ILogger logger, string toolName, PayloadMetadata met toolName, ServerProfile.Name, metadata.ArgCount, - metadata.ArgKeys); + metadata.ArgKeys + ); public static void LogCompletion(ILogger logger, string toolName, long durationMs, string outcome) { @@ -139,7 +146,8 @@ public static void LogCompletion(ILogger logger, string toolName, long durationM toolName, ServerProfile.Name, durationMs, - outcome); + outcome + ); var tags = new TagList { @@ -160,8 +168,7 @@ private static McpServerProfile ResolveServerProfile() private static string? ResolveServerVersion() { - var informationalVersion = Assembly.GetExecutingAssembly() - .GetCustomAttribute()?.InformationalVersion; + var informationalVersion = Assembly.GetExecutingAssembly().GetCustomAttribute()?.InformationalVersion; return informationalVersion?.Split(['+', '-'])[0]; } diff --git a/src/api/Elastic.Documentation.Mcp.Remote/Tools/CoherenceTools.cs b/src/api/Elastic.Documentation.Mcp.Remote/Tools/CoherenceTools.cs index 49a6ede81b..b1ce83a4d0 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/Tools/CoherenceTools.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/Tools/CoherenceTools.cs @@ -23,22 +23,21 @@ public class CoherenceTools(IFullSearchService fullSearchGateway, ILogger /// Checks documentation coherence for a given topic. ///
- [McpServerTool, McpToolName("check_{resource}_coherence"), Description( - "Checks how coherently a topic is covered across all {docs}. " + - "Use when reviewing documentation quality, auditing coverage of a feature or concept, " + - "or checking whether a topic is documented consistently across products and sections.")] + [McpServerTool, McpToolName("check_{resource}_coherence"), Description("Checks how coherently a topic is covered across all {docs}. " + + "Use when reviewing documentation quality, auditing coverage of a feature or concept, " + + "or checking whether a topic is documented consistently across products and sections.")] public async Task CheckCoherence( [Description("Topic or concept to check coherence for")] string topic, [Description("Maximum number of documents to analyze (default: 20)")] int limit = 20, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { var toolName = McpToolTelemetry.ResolveToolName("check_{resource}_coherence"); using var activity = McpToolTelemetry.StartActivity(toolName); - var payload = McpToolTelemetry.SetPayloadMetadata(activity, new Dictionary - { - ["topic"] = topic, - ["limit"] = limit - }); + var payload = McpToolTelemetry.SetPayloadMetadata( + activity, + new Dictionary { ["topic"] = topic, ["limit"] = limit } + ); McpToolTelemetry.LogStart(logger, toolName, payload); var duration = Stopwatch.StartNew(); var outcome = "failure"; @@ -47,13 +46,7 @@ public async Task CheckCoherence( { limit = Math.Clamp(limit, 5, 50); - var request = new FullSearchRequest - { - Query = topic, - PageNumber = 1, - PageSize = limit, - IncludeHighlighting = false - }; + var request = new FullSearchRequest { Query = topic, PageNumber = 1, PageSize = limit, IncludeHighlighting = false }; var result = await fullSearchGateway.SearchAsync(request, cancellationToken); @@ -80,14 +73,21 @@ public async Task CheckCoherence( DocsWithAiSummary = docsWithSummaries, DocsWithRagSummary = docsWithRagSummaries, CoverageScore = CalculateCoverageScore(result.TotalResults, navigationSections.Count, products.Count), - TopDocuments = result.Results.Take(5).Select(r => new CoherenceDocDto - { - Url = r.Url, - Title = r.Title, - AiShortSummary = r.AiShortSummary, - NavigationSection = r.NavigationSection, - Product = r.Product?.DisplayName - }).ToList() + TopDocuments = + result.Results + .Take(5) + .Select( + r => + new CoherenceDocDto + { + Url = r.Url, + Title = r.Title, + AiShortSummary = r.AiShortSummary, + NavigationSection = r.NavigationSection, + Product = r.Product?.DisplayName + } + ) + .ToList() }; McpToolTelemetry.MarkSuccess(activity); @@ -116,22 +116,21 @@ public async Task CheckCoherence( /// /// Finds potential inconsistencies in documentation for a given topic. /// - [McpServerTool, McpToolName("find_{resource}_inconsistencies"), Description( - "Finds potential inconsistencies across {docs} pages covering the same topic. " + - "Use when auditing docs quality, verifying that instructions don't contradict each other, " + - "or checking for overlapping content within a product area.")] + [McpServerTool, McpToolName("find_{resource}_inconsistencies"), Description("Finds potential inconsistencies across {docs} pages covering the same topic. " + + "Use when auditing docs quality, verifying that instructions don't contradict each other, " + + "or checking for overlapping content within a product area.")] public async Task FindInconsistencies( [Description("Topic or concept to check for inconsistencies")] string topic, [Description("Specific area to focus on (e.g., 'installation', 'configuration')")] string? focusArea = null, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { var toolName = McpToolTelemetry.ResolveToolName("find_{resource}_inconsistencies"); using var activity = McpToolTelemetry.StartActivity(toolName); - var payload = McpToolTelemetry.SetPayloadMetadata(activity, new Dictionary - { - ["topic"] = topic, - ["focusArea"] = focusArea - }); + var payload = McpToolTelemetry.SetPayloadMetadata( + activity, + new Dictionary { ["topic"] = topic, ["focusArea"] = focusArea } + ); McpToolTelemetry.LogStart(logger, toolName, payload); var duration = Stopwatch.StartNew(); var outcome = "failure"; @@ -140,13 +139,7 @@ public async Task FindInconsistencies( { var query = focusArea != null ? $"{topic} {focusArea}" : topic; - var request = new FullSearchRequest - { - Query = query, - PageNumber = 1, - PageSize = 30, - IncludeHighlighting = false - }; + var request = new FullSearchRequest { Query = query, PageNumber = 1, PageSize = 30, IncludeHighlighting = false }; var result = await fullSearchGateway.SearchAsync(request, cancellationToken); diff --git a/src/api/Elastic.Documentation.Mcp.Remote/Tools/DocumentTools.cs b/src/api/Elastic.Documentation.Mcp.Remote/Tools/DocumentTools.cs index 471d169d89..3fd1fbb684 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/Tools/DocumentTools.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/Tools/DocumentTools.cs @@ -23,23 +23,22 @@ public class DocumentTools(IDocumentGateway documentGateway, ILogger /// Gets a document by its URL. ///
- [McpServerTool, McpToolName("get_{scope}document_by_url"), Description( - "Retrieves a specific {docs} page by its URL. " + - "Use when the user provides a documentation URL, references a known page, " + - "or you need the full content and metadata of a specific doc. " + - "Returns title, AI summaries, headings, navigation context, and optionally the full body.")] + [McpServerTool, McpToolName("get_{scope}document_by_url"), Description("Retrieves a specific {docs} page by its URL. " + + "Use when the user provides a documentation URL, references a known page, " + + "or you need the full content and metadata of a specific doc. " + + "Returns title, AI summaries, headings, navigation context, and optionally the full body.")] public async Task GetDocumentByUrl( [Description("The URL of the document. Accepts a full URL (e.g. 'https://www.elastic.co/docs/deploy-manage/api-keys') or a path (e.g. '/docs/deploy-manage/api-keys'). Query strings, fragments, and trailing slashes are ignored.")] string url, [Description("Include full body content (default: false, set true for detailed analysis)")] bool includeBody = false, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { var toolName = McpToolTelemetry.ResolveToolName("get_{scope}document_by_url"); using var activity = McpToolTelemetry.StartActivity(toolName); - var payload = McpToolTelemetry.SetPayloadMetadata(activity, new Dictionary - { - ["url"] = url, - ["includeBody"] = includeBody - }); + var payload = McpToolTelemetry.SetPayloadMetadata( + activity, + new Dictionary { ["url"] = url, ["includeBody"] = includeBody } + ); McpToolTelemetry.LogStart(logger, toolName, payload); var duration = Stopwatch.StartNew(); var outcome = "failure"; @@ -53,7 +52,8 @@ public async Task GetDocumentByUrl( McpToolTelemetry.MarkFailure(activity, "document_not_found", "Document not found for the requested URL"); return JsonSerializer.Serialize( new ErrorResponse($"Document not found for URL: {url}"), - McpJsonContext.Default.ErrorResponse); + McpJsonContext.Default.ErrorResponse + ); } var response = new DocumentResponse @@ -69,22 +69,10 @@ public async Task GetDocumentByUrl( AiUseCases = result.AiUseCases, LastUpdated = result.LastUpdated, SourceUrl = result.SourceUrl, - Parents = result.Parents.Select(p => new ParentDto - { - Title = p.Title, - Url = p.Url - }).ToList(), + Parents = result.Parents.Select(p => new ParentDto { Title = p.Title, Url = p.Url }).ToList(), Headings = result.Headings.ToList(), - Product = result.Product != null ? new ProductDto - { - Id = result.Product.Id, - Repository = result.Product.Repository - } : null, - RelatedProducts = result.RelatedProducts?.Select(p => new ProductDto - { - Id = p.Id, - Repository = p.Repository - }).ToList(), + Product = result.Product != null ? new ProductDto { Id = result.Product.Id, Repository = result.Product.Repository } : null, + RelatedProducts = result.RelatedProducts?.Select(p => new ProductDto { Id = p.Id, Repository = p.Repository }).ToList(), Body = includeBody ? result.Body : null, BodyLength = result.Body?.Length ?? 0 }; @@ -115,20 +103,17 @@ public async Task GetDocumentByUrl( /// /// Analyzes the structure of a document. /// - [McpServerTool, McpToolName("analyze_{scope}document_structure"), Description( - "Analyzes the structure of a {docs} page. " + - "Use when evaluating page quality, checking heading hierarchy, or assessing AI enrichment status. " + - "Returns heading count, link count, parent pages, and whether AI summaries are present.")] + [McpServerTool, McpToolName("analyze_{scope}document_structure"), Description("Analyzes the structure of a {docs} page. " + + "Use when evaluating page quality, checking heading hierarchy, or assessing AI enrichment status. " + + "Returns heading count, link count, parent pages, and whether AI summaries are present.")] public async Task AnalyzeDocumentStructure( [Description("The URL of the document to analyze. Accepts a full URL (e.g. 'https://www.elastic.co/docs/deploy-manage/api-keys') or a path (e.g. '/docs/deploy-manage/api-keys'). Query strings, fragments, and trailing slashes are ignored.")] string url, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { var toolName = McpToolTelemetry.ResolveToolName("analyze_{scope}document_structure"); using var activity = McpToolTelemetry.StartActivity(toolName); - var payload = McpToolTelemetry.SetPayloadMetadata(activity, new Dictionary - { - ["url"] = url - }); + var payload = McpToolTelemetry.SetPayloadMetadata(activity, new Dictionary { ["url"] = url }); McpToolTelemetry.LogStart(logger, toolName, payload); var duration = Stopwatch.StartNew(); var outcome = "failure"; @@ -142,7 +127,8 @@ public async Task AnalyzeDocumentStructure( McpToolTelemetry.MarkFailure(activity, "document_not_found", "Document not found for the requested URL"); return JsonSerializer.Serialize( new ErrorResponse($"Document not found for URL: {url}"), - McpJsonContext.Default.ErrorResponse); + McpJsonContext.Default.ErrorResponse + ); } var response = new DocumentStructureResponse @@ -154,11 +140,7 @@ public async Task AnalyzeDocumentStructure( ParentCount = result.ParentCount, BodyLength = result.BodyLength, Headings = result.Headings.ToList(), - Parents = result.Parents.Select(p => new ParentDto - { - Title = p.Title, - Url = p.Url - }).ToList(), + Parents = result.Parents.Select(p => new ParentDto { Title = p.Title, Url = p.Url }).ToList(), AiEnrichment = new AiEnrichmentStatusDto { HasSummary = result.HasAiSummary, diff --git a/src/api/Elastic.Documentation.Mcp.Remote/Tools/SearchTools.cs b/src/api/Elastic.Documentation.Mcp.Remote/Tools/SearchTools.cs index c5686cd405..b993467b25 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/Tools/SearchTools.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/Tools/SearchTools.cs @@ -24,29 +24,32 @@ public class SearchTools(IFullSearchService fullSearchGateway, ILogger /// Performs semantic search across all Elastic documentation. ///
- [McpServerTool, McpToolName("search_{resource}"), Description( - "Searches all published {docs} by meaning. " + - "Use when the user asks about Elastic product features, needs to find existing docs pages, " + - "verify published content, or research what documentation exists on a topic. " + - "Returns relevant documents with AI summaries, relevance scores, and navigation context.")] + [McpServerTool, McpToolName("search_{resource}"), Description("Searches all published {docs} by meaning. " + + "Use when the user asks about Elastic product features, needs to find existing docs pages, " + + "verify published content, or research what documentation exists on a topic. " + + "Returns relevant documents with AI summaries, relevance scores, and navigation context.")] public async Task SemanticSearch( [Description("The search query - can be a question or keywords")] string query, [Description("Page number (1-based, default: 1)")] int pageNumber = 1, [Description("Number of results per page (default: 10, max: 50)")] int pageSize = 10, [Description("Filter by product ID (e.g., 'elasticsearch', 'kibana')")] string? productFilter = null, [Description("Filter by navigation section (e.g., 'reference', 'getting-started')")] string? sectionFilter = null, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { var toolName = McpToolTelemetry.ResolveToolName("search_{resource}"); using var activity = McpToolTelemetry.StartActivity(toolName); - var payload = McpToolTelemetry.SetPayloadMetadata(activity, new Dictionary - { - ["query"] = query, - ["pageNumber"] = pageNumber, - ["pageSize"] = pageSize, - ["productFilter"] = productFilter, - ["sectionFilter"] = sectionFilter - }); + var payload = McpToolTelemetry.SetPayloadMetadata( + activity, + new Dictionary + { + ["query"] = query, + ["pageNumber"] = pageNumber, + ["pageSize"] = pageSize, + ["productFilter"] = productFilter, + ["sectionFilter"] = sectionFilter + } + ); McpToolTelemetry.LogStart(logger, toolName, payload); var duration = Stopwatch.StartNew(); var outcome = "failure"; @@ -73,17 +76,23 @@ public async Task SemanticSearch( Query = query, TotalHits = result.TotalResults, IsSemanticQuery = result.IsSemanticQuery, - Results = result.Results.Select(r => new SearchResultDto - { - Url = r.Url, - Title = r.Title, - Description = r.Description, - Score = r.Score, - AiShortSummary = r.AiShortSummary, - NavigationSection = r.NavigationSection, - Product = r.Product?.DisplayName, - LastUpdated = r.LastUpdated - }).ToList() + Results = + result.Results + .Select( + r => + new SearchResultDto + { + Url = r.Url, + Title = r.Title, + Description = r.Description, + Score = r.Score, + AiShortSummary = r.AiShortSummary, + NavigationSection = r.NavigationSection, + Product = r.Product?.DisplayName, + LastUpdated = r.LastUpdated + } + ) + .ToList() }; McpToolTelemetry.MarkSuccess(activity); @@ -120,24 +129,22 @@ public async Task SemanticSearch( /// /// Finds documents related to a given topic or document URL. /// - [McpServerTool, McpToolName("find_related_{resource}"), Description( - "Finds {docs} pages related to a given topic. " + - "Use when exploring what documentation exists around a subject, building context for writing, " + - "or discovering related content the user should be aware of.")] + [McpServerTool, McpToolName("find_related_{resource}"), Description("Finds {docs} pages related to a given topic. " + + "Use when exploring what documentation exists around a subject, building context for writing, " + + "or discovering related content the user should be aware of.")] public async Task FindRelatedDocs( [Description("Topic or search terms to find related documents for")] string topic, [Description("Maximum number of related documents to return (default: 10)")] int limit = 10, [Description("Filter by product ID (e.g., 'elasticsearch', 'kibana')")] string? productFilter = null, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { var toolName = McpToolTelemetry.ResolveToolName("find_related_{resource}"); using var activity = McpToolTelemetry.StartActivity(toolName); - var payload = McpToolTelemetry.SetPayloadMetadata(activity, new Dictionary - { - ["topic"] = topic, - ["limit"] = limit, - ["productFilter"] = productFilter - }); + var payload = McpToolTelemetry.SetPayloadMetadata( + activity, + new Dictionary { ["topic"] = topic, ["limit"] = limit, ["productFilter"] = productFilter } + ); McpToolTelemetry.LogStart(logger, toolName, payload); var duration = Stopwatch.StartNew(); var outcome = "failure"; @@ -161,15 +168,21 @@ public async Task FindRelatedDocs( { Topic = topic, Count = result.Results.Count, - RelatedDocs = result.Results.Select(r => new RelatedDocDto - { - Url = r.Url, - Title = r.Title, - Description = r.Description, - Score = r.Score, - AiShortSummary = r.AiShortSummary, - Product = r.Product?.DisplayName - }).ToList() + RelatedDocs = + result.Results + .Select( + r => + new RelatedDocDto + { + Url = r.Url, + Title = r.Title, + Description = r.Description, + Score = r.Score, + AiShortSummary = r.AiShortSummary, + Product = r.Product?.DisplayName + } + ) + .ToList() }; McpToolTelemetry.MarkSuccess(activity); diff --git a/src/authoring/Elastic.Documentation.Refactor/FormatService.cs b/src/authoring/Elastic.Documentation.Refactor/FormatService.cs index ef8d6d4e84..53b0245c50 100644 --- a/src/authoring/Elastic.Documentation.Refactor/FormatService.cs +++ b/src/authoring/Elastic.Documentation.Refactor/FormatService.cs @@ -15,30 +15,19 @@ namespace Elastic.Documentation.Refactor; -public class FormatService( - ILoggerFactory logFactory, - IConfigurationContext configurationContext -) : IService +public class FormatService(ILoggerFactory logFactory, IConfigurationContext configurationContext) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); // List of formatters to apply - easily extensible for future formatting operations - private static readonly IFormatter[] Formatters = - [ - new IrregularSpaceFormatter() - // Future formatters can be added here: - // new TrailingWhitespaceFormatter(), - // new LineEndingFormatter(), - // etc. + private static readonly IFormatter[] Formatters = [new IrregularSpaceFormatter() + // Future formatters can be added here: + // new TrailingWhitespaceFormatter(), + // new LineEndingFormatter(), + // etc. ]; - public async Task Format( - IDiagnosticsCollector collector, - string? path, - bool checkOnly, - ScopedFileSystem fs, - Cancel ctx - ) + public async Task Format(IDiagnosticsCollector collector, string? path, bool checkOnly, ScopedFileSystem fs, Cancel ctx) { // Create BuildContext to load the documentation set var docFs = DocumentationFileSystem.Resolve(path); @@ -85,7 +74,10 @@ Cancel ctx _logger.LogInformation(""); // Emit error to trigger exit code 1 - collector.EmitError(string.Empty, $"{totalFilesModified} file(s) need formatting. Run 'docs-builder format --write' to apply changes."); + collector.EmitError( + string.Empty, + $"{totalFilesModified} file(s) need formatting. Run 'docs-builder format --write' to apply changes." + ); return false; } diff --git a/src/authoring/Elastic.Documentation.Refactor/Formatters/IrregularSpaceFormatter.cs b/src/authoring/Elastic.Documentation.Refactor/Formatters/IrregularSpaceFormatter.cs index 1bb00d6c62..f0320468cd 100644 --- a/src/authoring/Elastic.Documentation.Refactor/Formatters/IrregularSpaceFormatter.cs +++ b/src/authoring/Elastic.Documentation.Refactor/Formatters/IrregularSpaceFormatter.cs @@ -21,39 +21,63 @@ public class IrregularSpaceFormatter : IFormatter private static readonly char[] CharactersToRemove = [ '\u000B', // Line Tabulation (\v) - + '\u000C', // Form Feed (\f) - + '\u0085', // Next Line + '\u1680', // Ogham Space Mark + '\u180E', // Mongolian Vowel Separator - + '\ufeff', // Zero Width No-Break Space - + '\u200B', // Zero Width Space - + '\u2028', // Line Separator - '\u2029' // Paragraph Separator + + '\u2029' // Paragraph Separator + ]; // Characters to preserve (semantically meaningful) private static readonly char[] CharactersToPreserve = [ '\u00A0', // No-Break Space - + '\u2007', // Figure Space + '\u202F', // Narrow No-Break Space - '\u205F' // Medium Mathematical Space + + '\u205F' // Medium Mathematical Space + ]; // Characters to replace with regular spaces (visible but problematic) private static readonly char[] CharactersToReplace = [ '\u2000', // En Quad + '\u2001', // Em Quad + '\u2002', // En Space - + '\u2003', // Em Space - + '\u2004', // Tree-Per-Em + '\u2005', // Four-Per-Em + '\u2006', // Six-Per-Em + '\u2008', // Punctuation Space - + '\u2009', // Thin Space + '\u200A', // Hair Space - '\u3000' // Ideographic Space + + '\u3000' // Ideographic Space + ]; private static readonly SearchValues CharactersToRemoveValues = SearchValues.Create(CharactersToRemove); @@ -64,9 +88,11 @@ public FormatResult Format(string content) { // Quick check - if no irregular space characters, return original var span = content.AsSpan(); - if (span.IndexOfAny(CharactersToRemoveValues) == -1 && - span.IndexOfAny(CharactersToPreserveValues) == -1 && - span.IndexOfAny(CharactersToReplaceValues) == -1) + if ( + span.IndexOfAny(CharactersToRemoveValues) == -1 + && span.IndexOfAny(CharactersToPreserveValues) == -1 + && span.IndexOfAny(CharactersToReplaceValues) == -1 + ) return new FormatResult(content, 0); // Process each character with appropriate handling diff --git a/src/authoring/Elastic.Documentation.Refactor/Move.cs b/src/authoring/Elastic.Documentation.Refactor/Move.cs index 104399b8fe..68db010923 100644 --- a/src/authoring/Elastic.Documentation.Refactor/Move.cs +++ b/src/authoring/Elastic.Documentation.Refactor/Move.cs @@ -15,7 +15,12 @@ public record ChangeSet(IFileInfo From, IFileInfo To); public record Change(IFileInfo Source, string OriginalContent, string NewContent); public record LinkModification(string OldLink, string NewLink, string SourceFile, int LineNumber, int ColumnNumber); -public partial class Move(ILoggerFactory logFactory, IFileSystem readFileSystem, IFileSystem writeFileSystem, DocumentationSet documentationSet) +public partial class Move( + ILoggerFactory logFactory, + IFileSystem readFileSystem, + IFileSystem writeFileSystem, + DocumentationSet documentationSet +) { private readonly ILogger _logger = logFactory.CreateLogger(); @@ -52,53 +57,49 @@ private async Task SetupChanges(ChangeSet changeSet, Cancel ctx) var markdownLinkRegex = MarkdownLinkRegex(); - var change = Regex.Replace(sourceContent, markdownLinkRegex.ToString(), match => - { - var originalPath = match.Value.Substring(match.Value.IndexOf('(') + 1, match.Value.LastIndexOf(')') - match.Value.IndexOf('(') - 1); - - var newPath = originalPath; - var isAbsoluteStylePath = originalPath.StartsWith('/'); - if (!isAbsoluteStylePath) + var change = Regex.Replace( + sourceContent, + markdownLinkRegex.ToString(), + match => { - var targetDirectory = Path.GetDirectoryName(targetPath)!; - var sourceDirectory = Path.GetDirectoryName(sourcePath)!; - var fullPath = Path.GetFullPath(Path.Join(sourceDirectory, originalPath)); - var relativePath = Path.GetRelativePath(targetDirectory, fullPath); + var originalPath = match.Value.Substring( + match.Value.IndexOf('(') + 1, + match.Value.LastIndexOf(')') - match.Value.IndexOf('(') - 1 + ); - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - relativePath = relativePath.Replace('\\', '/'); + var newPath = originalPath; + var isAbsoluteStylePath = originalPath.StartsWith('/'); + if (!isAbsoluteStylePath) + { + var targetDirectory = Path.GetDirectoryName(targetPath)!; + var sourceDirectory = Path.GetDirectoryName(sourcePath)!; + var fullPath = Path.GetFullPath(Path.Join(sourceDirectory, originalPath)); + var relativePath = Path.GetRelativePath(targetDirectory, fullPath); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + relativePath = relativePath.Replace('\\', '/'); - newPath = originalPath.StartsWith("./", OrdinalIgnoreCase) && !relativePath.StartsWith("./", OrdinalIgnoreCase) - ? "./" + relativePath - : relativePath; + newPath = originalPath.StartsWith("./", OrdinalIgnoreCase) && !relativePath.StartsWith("./", OrdinalIgnoreCase) + ? "./" + relativePath + : relativePath; + } + var newLink = $"[{match.Groups[1].Value}]({newPath})"; + var lineNumber = sourceContent[..match.Index].Count(c => c == '\n') + 1; + var columnNumber = match.Index - sourceContent.LastIndexOf('\n', match.Index); + if (!_linkModifications.ContainsKey(changeSet)) + _linkModifications[changeSet] = []; + + _linkModifications[changeSet].Add(new LinkModification(match.Value, newLink, sourcePath, lineNumber, columnNumber)); + return newLink; } - var newLink = $"[{match.Groups[1].Value}]({newPath})"; - var lineNumber = sourceContent[..match.Index].Count(c => c == '\n') + 1; - var columnNumber = match.Index - sourceContent.LastIndexOf('\n', match.Index); - if (!_linkModifications.ContainsKey(changeSet)) - _linkModifications[changeSet] = []; - - _linkModifications[changeSet].Add(new LinkModification( - match.Value, - newLink, - sourcePath, - lineNumber, - columnNumber - )); - return newLink; - }); + ); _changes[changeSet] = [new Change(changeSet.From, sourceContent, change)]; foreach (var markdownFile in documentationSet.MarkdownFiles) { - await ProcessMarkdownFile( - changeSet, - markdownFile, - ctx - ); + await ProcessMarkdownFile(changeSet, markdownFile, ctx); } - } private async Task MoveAndRewriteLinks(bool isDryRun, Cancel ctx) @@ -130,7 +131,6 @@ private async Task MoveAndRewriteLinks(bool isDryRun, Cancel ctx) if (!filePath.Directory!.Exists) _ = writeFileSystem.Directory.CreateDirectory(filePath.Directory.FullName); await writeFileSystem.File.WriteAllTextAsync(filePath.FullName, newContent, ctx); - } var targetDirectory = Path.GetDirectoryName(changeSet.To.FullName); @@ -218,16 +218,23 @@ private bool ValidateInputs(string source, string target, out IFileInfo[] fromFi if (toDirectory.FullName.StartsWith(fromDirectory.FullName, OrdinalIgnoreCase)) { - _logger.LogError("Can not move source directory '{SourceDirectory}' to a '{TargetFile}'", toDirectory.FullName, toFile.FullName); + _logger.LogError( + "Can not move source directory '{SourceDirectory}' to a '{TargetFile}'", + toDirectory.FullName, + toFile.FullName + ); return false; } fromFiles = fromDirectory.GetFiles("*.md", SearchOption.AllDirectories); - toFiles = [.. fromFiles.Select(f => - { - var relative = Path.GetRelativePath(fromDirectory.FullName, f.FullName); - return readFileSystem.FileInfo.New(Path.Join(toDirectory.FullName, relative)); - })]; + toFiles = + [ + .. fromFiles.Select(f => + { + var relative = Path.GetRelativePath(fromDirectory.FullName, f.FullName); + return readFileSystem.FileInfo.New(Path.Join(toDirectory.FullName, relative)); + }) + ]; } return true; @@ -275,17 +282,12 @@ string targetPath absoluteStyleTarget = absoluteStyleTarget.Replace('\\', '/'); } - return ( - relativeSource, - relativeSourceWithDotSlash, - absolutStyleSource, - absoluteStyleTarget - ); + return (relativeSource, relativeSourceWithDotSlash, absolutStyleSource, absoluteStyleTarget); } private static string BuildLinkPattern( - (string relativeSource, string relativeSourceWithDotSlash, string absolutStyleSource, string _) pathInfo) => - $@"\[([^\]]*)\]\((?:{pathInfo.relativeSource}|{pathInfo.relativeSourceWithDotSlash}|{pathInfo.absolutStyleSource})(?:#[^\)]*?)?\)"; + (string relativeSource, string relativeSourceWithDotSlash, string absolutStyleSource, string _) pathInfo + ) => $@"\[([^\]]*)\]\((?:{pathInfo.relativeSource}|{pathInfo.relativeSourceWithDotSlash}|{pathInfo.absolutStyleSource})(?:#[^\)]*?)?\)"; private string ReplaceLinks( ChangeSet changeSet, @@ -293,16 +295,18 @@ private string ReplaceLinks( string linkPattern, string absoluteStyleTarget, string target, - MarkdownFile value) => + MarkdownFile value + ) => Regex.Replace( content, linkPattern, match => { - var originalPath = match.Value.Substring(match.Value.IndexOf('(') + 1, match.Value.LastIndexOf(')') - match.Value.IndexOf('(') - 1); - var anchor = originalPath.Contains('#') - ? originalPath[originalPath.IndexOf('#')..] - : ""; + var originalPath = match.Value.Substring( + match.Value.IndexOf('(') + 1, + match.Value.LastIndexOf(')') - match.Value.IndexOf('(') - 1 + ); + var anchor = originalPath.Contains('#') ? originalPath[originalPath.IndexOf('#')..] : ""; string newLink; if (originalPath.StartsWith('/')) @@ -322,15 +326,12 @@ private string ReplaceLinks( var columnNumber = match.Index - content.LastIndexOf('\n', match.Index); if (!_linkModifications.ContainsKey(changeSet)) _linkModifications[changeSet] = []; - _linkModifications[changeSet].Add(new LinkModification( - match.Value, - newLink, - value.SourceFile.FullName, - lineNumber, - columnNumber - )); + _linkModifications[changeSet].Add( + new LinkModification(match.Value, newLink, value.SourceFile.FullName, lineNumber, columnNumber) + ); return newLink; - }); + } + ); [GeneratedRegex(@"\[([^\]]*)\]\(((?:\.{0,2}\/)?[^:)]+\.md(?:#[^)]*)?)\)", RegexOptions.Compiled)] private static partial Regex MarkdownLinkRegex(); diff --git a/src/authoring/Elastic.Documentation.Refactor/MoveFileService.cs b/src/authoring/Elastic.Documentation.Refactor/MoveFileService.cs index 2c037d0104..d84ab0feee 100644 --- a/src/authoring/Elastic.Documentation.Refactor/MoveFileService.cs +++ b/src/authoring/Elastic.Documentation.Refactor/MoveFileService.cs @@ -14,10 +14,7 @@ namespace Elastic.Documentation.Refactor; -public class MoveFileService( - ILoggerFactory logFactory, - IConfigurationContext configurationContext -) : IService +public class MoveFileService(ILoggerFactory logFactory, IConfigurationContext configurationContext) : IService { public async Task Move( IDiagnosticsCollector collector, diff --git a/src/authoring/Elastic.Documentation.Refactor/Tracking/IntegrationGitRepositoryTracker.cs b/src/authoring/Elastic.Documentation.Refactor/Tracking/IntegrationGitRepositoryTracker.cs index cbf827777b..dd4ad7cad7 100644 --- a/src/authoring/Elastic.Documentation.Refactor/Tracking/IntegrationGitRepositoryTracker.cs +++ b/src/authoring/Elastic.Documentation.Refactor/Tracking/IntegrationGitRepositoryTracker.cs @@ -47,7 +47,6 @@ IEnumerable GetChanges() yield return new RenamedGitChange(parts[0], parts[1], GitChangeType.Renamed); } } - } } } diff --git a/src/authoring/Elastic.Documentation.Refactor/Tracking/LocalChangesService.cs b/src/authoring/Elastic.Documentation.Refactor/Tracking/LocalChangesService.cs index 1882bf3f14..eac8dcf91c 100644 --- a/src/authoring/Elastic.Documentation.Refactor/Tracking/LocalChangesService.cs +++ b/src/authoring/Elastic.Documentation.Refactor/Tracking/LocalChangesService.cs @@ -14,10 +14,7 @@ namespace Elastic.Documentation.Refactor.Tracking; -public class LocalChangeTrackingService( - ILoggerFactory logFactory, - IConfigurationContext configurationContext -) : IService +public class LocalChangeTrackingService(ILoggerFactory logFactory, IConfigurationContext configurationContext) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); @@ -44,7 +41,10 @@ public Task ValidateRedirects(IDiagnosticsCollector collector, string? pat var root = Paths.FindGitRoot(buildContext.DocumentationSourceDirectory); if (root is null) { - collector.EmitError(redirectFile.Source, $"Unable to determine the root of the source directory {buildContext.DocumentationSourceDirectory}."); + collector.EmitError( + redirectFile.Source, + $"Unable to determine the root of the source directory {buildContext.DocumentationSourceDirectory}." + ); return Task.FromResult(false); } var relativePath = Path.GetRelativePath(root.FullName, buildContext.DocumentationSourceDirectory.FullName); @@ -68,7 +68,10 @@ public Task ValidateRedirects(IDiagnosticsCollector collector, string? pat foreach (var change in deletedAndRenamed) { var lookupPath = change is RenamedGitChange renamed ? renamed.OldFilePath : change.FilePath; - var docSetRelativePath = Path.GetRelativePath(buildContext.DocumentationSourceDirectory.FullName, Path.Join(root.FullName, lookupPath)); + var docSetRelativePath = Path.GetRelativePath( + buildContext.DocumentationSourceDirectory.FullName, + Path.Join(root.FullName, lookupPath) + ); var rootRelativePath = Path.GetRelativePath(root.FullName, Path.Join(root.FullName, lookupPath)); if (buildContext.Configuration.IsExcluded(docSetRelativePath.OptionalWindowsReplace())) continue; @@ -76,22 +79,34 @@ public Task ValidateRedirects(IDiagnosticsCollector collector, string? pat continue; if (redirects.ContainsKey(rootRelativePath)) { - collector.EmitError(redirectFile.Source, - $"Redirect contains path relative to root '{rootRelativePath}' but should be relative to the documentation set '{docSetRelativePath}'"); + collector.EmitError( + redirectFile.Source, + $"Redirect contains path relative to root '{rootRelativePath}' but should be relative to the documentation set '{docSetRelativePath}'" + ); continue; } missingCount++; if (change is RenamedGitChange rename) - collector.EmitError(redirectFile.Source, $"Missing '{docSetRelativePath}' in redirects.yml. '{rename.OldFilePath}' was renamed to '{rename.NewFilePath}' but it has no redirect configuration set."); + collector.EmitError( + redirectFile.Source, + $"Missing '{docSetRelativePath}' in redirects.yml. '{rename.OldFilePath}' was renamed to '{rename.NewFilePath}' but it has no redirect configuration set." + ); else if (change.ChangeType is GitChangeType.Deleted) - collector.EmitError(redirectFile.Source, $"Missing '{docSetRelativePath}' in redirects.yml. '{change.FilePath}' was deleted but it has no redirect targets. This will lead to broken links."); + collector.EmitError( + redirectFile.Source, + $"Missing '{docSetRelativePath}' in redirects.yml. '{change.FilePath}' was deleted but it has no redirect targets. This will lead to broken links." + ); } if (missingCount != 0) { var relativeRedirectFile = Path.GetRelativePath(root.FullName, redirectFile.Source.FullName); - _logger.LogInformation("Found {Count} changes that still require updates to: {RedirectFile}", missingCount, relativeRedirectFile); + _logger.LogInformation( + "Found {Count} changes that still require updates to: {RedirectFile}", + missingCount, + relativeRedirectFile + ); } return Task.FromResult(collector.Errors == 0); diff --git a/src/authoring/Elastic.Documentation.Refactor/Tracking/LocalGitRepositoryTracker.cs b/src/authoring/Elastic.Documentation.Refactor/Tracking/LocalGitRepositoryTracker.cs index 12a29c9e37..0b101a48ed 100644 --- a/src/authoring/Elastic.Documentation.Refactor/Tracking/LocalGitRepositoryTracker.cs +++ b/src/authoring/Elastic.Documentation.Refactor/Tracking/LocalGitRepositoryTracker.cs @@ -9,8 +9,12 @@ namespace Elastic.Documentation.Refactor.Tracking; -public class LocalGitRepositoryTracker(ILoggerFactory logFactory, IDiagnosticsCollector collector, IDirectoryInfo workingDirectory, string lookupPath) - : ExternalCommandExecutor(collector, workingDirectory), IRepositoryTracker +public class LocalGitRepositoryTracker( + ILoggerFactory logFactory, + IDiagnosticsCollector collector, + IDirectoryInfo workingDirectory, + string lookupPath +) : ExternalCommandExecutor(collector, workingDirectory), IRepositoryTracker { /// protected override ILogger Logger { get; } = logFactory.CreateLogger(); diff --git a/src/authoring/Elastic.LegacyDocs.Migration/ArchiveDocsetGenerator.cs b/src/authoring/Elastic.LegacyDocs.Migration/ArchiveDocsetGenerator.cs index d6c9a78573..3a178513e4 100644 --- a/src/authoring/Elastic.LegacyDocs.Migration/ArchiveDocsetGenerator.cs +++ b/src/authoring/Elastic.LegacyDocs.Migration/ArchiveDocsetGenerator.cs @@ -63,8 +63,7 @@ public async Task GenerateAsync(LegacyConf conf, ArchiveGeneratorOptions options YamlWriter.WriteTocYaml(Path.Combine(versionDir, "toc.yml"), fileEntries); versionEntries.Add(new TocEntry { Folder = versionLabel }); - logger.LogInformation("Wrote {PageCount} pages for {Prefix}/{Version}", - pages.Count, book.Prefix, versionLabel); + logger.LogInformation("Wrote {PageCount} pages for {Prefix}/{Version}", pages.Count, book.Prefix, versionLabel); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -86,7 +85,11 @@ public async Task GenerateAsync(LegacyConf conf, ArchiveGeneratorOptions options } private async Task> ProcessBookVersion( - LegacyBook book, BranchRef version, ArchiveGeneratorOptions options, CancellationToken ct) + LegacyBook book, + BranchRef version, + ArchiveGeneratorOptions options, + CancellationToken ct + ) { var versionLabel = version.VersionLabel; var sources = await options.RepoManager.ResolveSourcesAsync(book, version, ct); @@ -108,27 +111,18 @@ private async Task> ProcessBookVersion( var basePath = Path.GetDirectoryName(indexPath) ?? primarySource.LocalPath; var parserOptions = new AsciidocParserOptions { - Attributes = new Dictionary - { - ["branch"] = versionLabel, - ["doc-tests-src"] = primarySource.LocalPath - } + Attributes = new Dictionary { ["branch"] = versionLabel, ["doc-tests-src"] = primarySource.LocalPath } }; var parser = new AsciidocParser(parserOptions); var document = parser.Parse(content, basePath); - var emitterOptions = new MarkdownEmitterOptions - { - BookPrefix = book.Prefix, - Version = versionLabel - }; + var emitterOptions = new MarkdownEmitterOptions { BookPrefix = book.Prefix, Version = versionLabel }; var emitter = new MarkdownEmitter(emitterOptions); return PageChunker.Chunk(document, book.Chunk, emitter); } - private static async Task> WritePages( - IReadOnlyList pages, string directory, CancellationToken ct) + private static async Task> WritePages(IReadOnlyList pages, string directory, CancellationToken ct) { var entries = new List(); foreach (var page in pages) @@ -152,16 +146,13 @@ internal static List GetVersionsToProcess(LegacyBook book, bool allVe if (!string.IsNullOrEmpty(book.Current)) _ = selected.Add(book.Current); - var grouped = branches - .Select(b => (Branch: b, Parsed: TryParseMajorMinor(b.VersionLabel))) + var grouped = branches.Select(b => (Branch: b, Parsed: TryParseMajorMinor(b.VersionLabel))) .Where(x => x.Parsed.HasValue) .GroupBy(x => x.Parsed!.Value.Major); foreach (var group in grouped) { - var topTwo = group - .OrderByDescending(x => x.Parsed!.Value.Minor) - .Take(2); + var topTwo = group.OrderByDescending(x => x.Parsed!.Value.Minor).Take(2); foreach (var (branch, _) in topTwo) _ = selected.Add(branch.VersionLabel); @@ -175,18 +166,15 @@ private static List FilterByMinVersion(IEnumerable branche if (minMajor is null) return branches.ToList(); - return branches - .Where(b => - { - var parsed = TryParseMajorMinor(b.VersionLabel); - return parsed.HasValue && parsed.Value.Major >= minMajor; - }) - .ToList(); + return branches.Where(b => + { + var parsed = TryParseMajorMinor(b.VersionLabel); + return parsed.HasValue && parsed.Value.Major >= minMajor; + }).ToList(); } private static List SortBranchesDescending(IEnumerable branches) => - branches - .Select(b => (Branch: b, Parsed: TryParseMajorMinor(b.VersionLabel))) + branches.Select(b => (Branch: b, Parsed: TryParseMajorMinor(b.VersionLabel))) .OrderByDescending(x => x.Parsed?.Major ?? 0) .ThenByDescending(x => x.Parsed?.Minor ?? 0) .Select(x => x.Branch) diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/AsciidocLexer.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/AsciidocLexer.cs index 9e3f5e993f..404a9abad5 100644 --- a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/AsciidocLexer.cs +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/AsciidocLexer.cs @@ -170,7 +170,12 @@ public static IReadOnlyList Tokenize(string content) if (IsMatchingDelimiter(line, verbatimDelimiter)) { inVerbatimBlock = false; - tokens.Add(new Token(TokenType.BlockDelimiter, line, lineNumber, new TokenMetadata { DelimiterChar = verbatimDelimiter[..1] })); + tokens.Add(new Token( + TokenType.BlockDelimiter, + line, + lineNumber, + new TokenMetadata { DelimiterChar = verbatimDelimiter[..1] } + )); } else { @@ -238,22 +243,29 @@ public static IReadOnlyList Tokenize(string content) var condStart = ConditionalStartRegex.Match(line); if (condStart.Success) { - tokens.Add(new Token(TokenType.ConditionalStart, line, lineNumber, new TokenMetadata - { - Condition = condStart.Groups[2].Value, - Content = condStart.Groups[3].Value, - BlockStyle = condStart.Groups[1].Value - })); + tokens.Add(new Token( + TokenType.ConditionalStart, + line, + lineNumber, + new TokenMetadata + { + Condition = condStart.Groups[2].Value, + Content = condStart.Groups[3].Value, + BlockStyle = condStart.Groups[1].Value + } + )); continue; } var condEnd = ConditionalEndRegex.Match(line); if (condEnd.Success) { - tokens.Add(new Token(TokenType.ConditionalEnd, line, lineNumber, new TokenMetadata - { - Condition = condEnd.Groups[1].Value - })); + tokens.Add(new Token( + TokenType.ConditionalEnd, + line, + lineNumber, + new TokenMetadata { Condition = condEnd.Groups[1].Value } + )); continue; } @@ -311,64 +323,60 @@ private static bool TryMatchToken(string line, int lineNumber, out Token token) var m = SectionRegex.Match(line); if (m.Success) { - token = new Token(TokenType.SectionTitle, line, lineNumber, new TokenMetadata - { - Level = m.Groups[1].Value.Length - 1, - Title = m.Groups[2].Value.Trim() - }); + token = + new Token( + TokenType.SectionTitle, + line, + lineNumber, + new TokenMetadata { Level = m.Groups[1].Value.Length - 1, Title = m.Groups[2].Value.Trim() } + ); return true; } m = AttributeUnsetRegex.Match(line); if (m.Success) { - token = new Token(TokenType.AttributeUnset, line, lineNumber, new TokenMetadata - { - AttributeName = m.Groups[1].Value - }); + token = new Token(TokenType.AttributeUnset, line, lineNumber, new TokenMetadata { AttributeName = m.Groups[1].Value }); return true; } m = AttributeEntryRegex.Match(line); if (m.Success) { - token = new Token(TokenType.AttributeEntry, line, lineNumber, new TokenMetadata - { - AttributeName = m.Groups[1].Value, - AttributeValue = m.Groups[2].Value - }); + token = + new Token( + TokenType.AttributeEntry, + line, + lineNumber, + new TokenMetadata { AttributeName = m.Groups[1].Value, AttributeValue = m.Groups[2].Value } + ); return true; } m = BlockAnchorRegex.Match(line); if (m.Success) { - token = new Token(TokenType.BlockAnchor, line, lineNumber, new TokenMetadata - { - Id = m.Groups[1].Value - }); + token = new Token(TokenType.BlockAnchor, line, lineNumber, new TokenMetadata { Id = m.Groups[1].Value }); return true; } m = ConditionalStartRegex.Match(line); if (m.Success) { - token = new Token(TokenType.ConditionalStart, line, lineNumber, new TokenMetadata - { - Condition = m.Groups[2].Value, - Content = m.Groups[3].Value, - BlockStyle = m.Groups[1].Value - }); + token = + new Token( + TokenType.ConditionalStart, + line, + lineNumber, + new TokenMetadata { Condition = m.Groups[2].Value, Content = m.Groups[3].Value, BlockStyle = m.Groups[1].Value } + ); return true; } m = ConditionalEndRegex.Match(line); if (m.Success) { - token = new Token(TokenType.ConditionalEnd, line, lineNumber, new TokenMetadata - { - Condition = m.Groups[1].Value - }); + token = new Token(TokenType.ConditionalEnd, line, lineNumber, new TokenMetadata { Condition = m.Groups[1].Value }); return true; } @@ -376,11 +384,13 @@ private static bool TryMatchToken(string line, int lineNumber, out Token token) if (m.Success) { var attrs = ParseBlockAttributeContent(m.Groups[2].Value); - token = new Token(TokenType.IncludeDirective, line, lineNumber, new TokenMetadata - { - Path = m.Groups[1].Value, - NamedAttributes = attrs - }); + token = + new Token( + TokenType.IncludeDirective, + line, + lineNumber, + new TokenMetadata { Path = m.Groups[1].Value, NamedAttributes = attrs } + ); return true; } @@ -388,23 +398,31 @@ private static bool TryMatchToken(string line, int lineNumber, out Token token) if (m.Success) { var attrs = ParseInlineAttributes(m.Groups[2].Value); - token = new Token(TokenType.ImageBlock, line, lineNumber, new TokenMetadata - { - Path = m.Groups[1].Value, - Title = attrs.GetValueOrDefault("alt") ?? attrs.GetValueOrDefault("0"), - NamedAttributes = attrs - }); + token = + new Token( + TokenType.ImageBlock, + line, + lineNumber, + new TokenMetadata + { + Path = m.Groups[1].Value, + Title = attrs.GetValueOrDefault("alt") ?? attrs.GetValueOrDefault("0"), + NamedAttributes = attrs + } + ); return true; } m = AdmonitionRegex.Match(line); if (m.Success) { - token = new Token(TokenType.AdmonitionParagraph, line, lineNumber, new TokenMetadata - { - BlockStyle = m.Groups[1].Value, - Content = m.Groups[2].Value - }); + token = + new Token( + TokenType.AdmonitionParagraph, + line, + lineNumber, + new TokenMetadata { BlockStyle = m.Groups[1].Value, Content = m.Groups[2].Value } + ); return true; } @@ -430,32 +448,33 @@ private static bool TryMatchToken(string line, int lineNumber, out Token token) m = UnorderedListRegex.Match(line); if (m.Success) { - token = new Token(TokenType.ListItemUnordered, line, lineNumber, new TokenMetadata - { - Level = m.Groups[1].Value.Length, - Content = m.Groups[2].Value - }); + token = + new Token( + TokenType.ListItemUnordered, + line, + lineNumber, + new TokenMetadata { Level = m.Groups[1].Value.Length, Content = m.Groups[2].Value } + ); return true; } m = OrderedListRegex.Match(line); if (m.Success) { - token = new Token(TokenType.ListItemOrdered, line, lineNumber, new TokenMetadata - { - Level = m.Groups[1].Value.Length, - Content = m.Groups[2].Value - }); + token = + new Token( + TokenType.ListItemOrdered, + line, + lineNumber, + new TokenMetadata { Level = m.Groups[1].Value.Length, Content = m.Groups[2].Value } + ); return true; } m = CommentRegex.Match(line); if (m.Success) { - token = new Token(TokenType.Comment, line, lineNumber, new TokenMetadata - { - Content = m.Groups[1].Value - }); + token = new Token(TokenType.Comment, line, lineNumber, new TokenMetadata { Content = m.Groups[1].Value }); return true; } @@ -487,23 +506,20 @@ private static bool TryMatchToken(string line, int lineNumber, out Token token) language = second; } - token = new Token(TokenType.BlockAttribute, line, lineNumber, new TokenMetadata - { - BlockStyle = style, - Language = language, - Content = content, - NamedAttributes = parsed - }); + token = + new Token( + TokenType.BlockAttribute, + line, + lineNumber, + new TokenMetadata { BlockStyle = style, Language = language, Content = content, NamedAttributes = parsed } + ); return true; } m = BlockTitleRegex.Match(line); if (m.Success) { - token = new Token(TokenType.BlockTitle, line, lineNumber, new TokenMetadata - { - Title = m.Groups[1].Value - }); + token = new Token(TokenType.BlockTitle, line, lineNumber, new TokenMetadata { Title = m.Groups[1].Value }); return true; } @@ -515,12 +531,13 @@ private static bool TryMatchToken(string line, int lineNumber, out Token token) var desc = m.Groups[3].Value; if (!term.Contains("://") && !term.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { - token = new Token(TokenType.DescriptionListItem, line, lineNumber, new TokenMetadata - { - Title = term, - Content = desc, - Level = separator.Length - }); + token = + new Token( + TokenType.DescriptionListItem, + line, + lineNumber, + new TokenMetadata { Title = term, Content = desc, Level = separator.Length } + ); return true; } } diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/AsciidocParser.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/AsciidocParser.cs index 1b14a007a8..50cfd3c844 100644 --- a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/AsciidocParser.cs +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/AsciidocParser.cs @@ -102,27 +102,22 @@ public AsciidocDocument Parse(string content, string basePath) SetAttribute(token.Metadata!.AttributeName!, token.Metadata.AttributeValue); _pos++; break; - case TokenType.AttributeUnset: _ = _attributes.Remove(token.Metadata!.AttributeName!); _pos++; break; - case TokenType.BlockAnchor: pendingId = token.Metadata!.Id; _pos++; break; - case TokenType.BlockTitle: pendingTitle = token.Metadata!.Title; _pos++; break; - case TokenType.BlockAttribute: pendingBlockAttr = token.Metadata; _pos++; break; - case TokenType.SectionTitle: if (doc.Title is null && token.Metadata!.Level == 1) { @@ -145,12 +140,10 @@ public AsciidocDocument Parse(string content, string basePath) pendingBlockAttr = null; } break; - case TokenType.Blank: case TokenType.Comment: _pos++; break; - case TokenType.IncludeDirective: var included = ProcessInclude(token); if (included != null) @@ -160,7 +153,6 @@ public AsciidocDocument Parse(string content, string basePath) pendingBlockAttr = null; _pos++; break; - default: var block = ParseBlock(pendingId, pendingTitle, pendingBlockAttr); if (block != null) @@ -183,8 +175,8 @@ private SectionNode ParseSection(string? id, string? title, TokenMetadata? block var level = token.Metadata!.Level!.Value; var sectionTitle = SubstituteAttributes(title ?? token.Metadata.Title!); var sectionId = id ?? ExtractInlineAnchor(sectionTitle); - var isDiscrete = string.Equals(blockAttr?.BlockStyle, "discrete", StringComparison.OrdinalIgnoreCase) - || string.Equals(blockAttr?.BlockStyle, "float", StringComparison.OrdinalIgnoreCase); + var isDiscrete = string.Equals(blockAttr?.BlockStyle, "discrete", StringComparison.OrdinalIgnoreCase) || + string.Equals(blockAttr?.BlockStyle, "float", StringComparison.OrdinalIgnoreCase); _pos++; var children = new List(); @@ -210,28 +202,23 @@ private SectionNode ParseSection(string? id, string? title, TokenMetadata? block _pos++; pendingStart = _pos; break; - case TokenType.AttributeUnset: _ = _attributes.Remove(cur.Metadata!.AttributeName!); _pos++; pendingStart = _pos; break; - case TokenType.BlockAnchor: pendingId = cur.Metadata!.Id; _pos++; break; - case TokenType.BlockTitle: pendingTitle = cur.Metadata!.Title; _pos++; break; - case TokenType.BlockAttribute: pendingBlockAttr = cur.Metadata; _pos++; break; - case TokenType.SectionTitle: var childSection = ParseSection(pendingId, null, pendingBlockAttr); children.Add(childSection); @@ -240,12 +227,10 @@ private SectionNode ParseSection(string? id, string? title, TokenMetadata? block pendingBlockAttr = null; pendingStart = _pos; break; - case TokenType.Blank: case TokenType.Comment: _pos++; break; - case TokenType.IncludeDirective: var included = ProcessInclude(cur); if (included != null) @@ -256,7 +241,6 @@ private SectionNode ParseSection(string? id, string? title, TokenMetadata? block _pos++; pendingStart = _pos; break; - default: var block = ParseBlock(pendingId, pendingTitle, pendingBlockAttr); if (block != null) @@ -375,32 +359,26 @@ TokenType.Text when CalloutItemRegex().IsMatch(token.Raw) => ParseCalloutList(), return delimChar switch { - "-" when openingDelim.Length >= 4 || style == "source" => new CodeBlockNode - { - Language = blockAttr?.Language, - Source = string.Join('\n', contentLines), - Callouts = callouts - }, + "-" when openingDelim.Length >= 4 || style == "source" => + new CodeBlockNode { Language = blockAttr?.Language, Source = string.Join('\n', contentLines), Callouts = callouts }, "." => new LiteralBlockNode(string.Join('\n', contentLines)), - "=" when IsAdmonitionStyle(style) => new AdmonitionNode - { - Type = ParseAdmonitionType(style!), - Children = children - }, + "=" when IsAdmonitionStyle(style) => new AdmonitionNode { Type = ParseAdmonitionType(style!), Children = children }, "=" => new ExampleNode { Children = children }, "*" => new SidebarNode { Children = children }, "+" => new PassthroughNode(string.Join('\n', contentLines)), - "-" when openingDelim == "--" => style switch - { - "source" => new CodeBlockNode { Language = blockAttr?.Language, Source = string.Join('\n', contentLines) }, - _ when IsAdmonitionStyle(style) => new AdmonitionNode + "-" when openingDelim == "--" => + style switch { - Type = ParseAdmonitionType(style!), - Children = children.Count > 0 ? children : WrapAsBlocks(contentLines) + "source" => new CodeBlockNode { Language = blockAttr?.Language, Source = string.Join('\n', contentLines) }, + _ when IsAdmonitionStyle(style) => + new AdmonitionNode + { + Type = ParseAdmonitionType(style!), + Children = children.Count > 0 ? children : WrapAsBlocks(contentLines) + }, + "sidebar" => new SidebarNode { Children = children.Count > 0 ? children : WrapAsBlocks(contentLines) }, + _ => new OpenBlockNode { Children = children.Count > 0 ? children : WrapAsBlocks(contentLines) } }, - "sidebar" => new SidebarNode { Children = children.Count > 0 ? children : WrapAsBlocks(contentLines) }, - _ => new OpenBlockNode { Children = children.Count > 0 ? children : WrapAsBlocks(contentLines) } - }, "/" => null, _ => new OpenBlockNode { Children = children.Count > 0 ? children : WrapAsBlocks(contentLines) } }; @@ -435,13 +413,14 @@ TokenType.Text when CalloutItemRegex().IsMatch(token.Raw) => ParseCalloutList(), private List ResolveVerbatimIncludes(List lines) { // Fast path: nothing to do - if (!lines.Any(l => - l.Contains("include-tagged::", StringComparison.Ordinal) || - l.Contains("include::", StringComparison.Ordinal) || - l.Contains("ifeval::", StringComparison.Ordinal) || - l.Contains("ifdef::", StringComparison.Ordinal) || - l.Contains("ifndef::", StringComparison.Ordinal) || - l.Contains("endif::", StringComparison.Ordinal))) + if ( + !lines.Any( + l => + l.Contains("include-tagged::", StringComparison.Ordinal) || l.Contains("include::", StringComparison.Ordinal) || + l.Contains("ifeval::", StringComparison.Ordinal) || l.Contains("ifdef::", StringComparison.Ordinal) || + l.Contains("ifndef::", StringComparison.Ordinal) || l.Contains("endif::", StringComparison.Ordinal) + ) + ) return lines; var result = new List(lines.Count); @@ -483,9 +462,7 @@ private List ResolveVerbatimIncludes(List lines) private IEnumerable ResolveTaggedInclude(string rawPathToken, string tag, string originalLine) { var rawPath = SubstituteAttributes(rawPathToken); - var resolvedPath = Path.IsPathRooted(rawPath) - ? Path.GetFullPath(rawPath) - : Path.GetFullPath(Path.Combine(_basePath, rawPath)); + var resolvedPath = Path.IsPathRooted(rawPath) ? Path.GetFullPath(rawPath) : Path.GetFullPath(Path.Combine(_basePath, rawPath)); var fileContent = ReadFile(resolvedPath); if (fileContent is null) @@ -500,9 +477,7 @@ private IEnumerable ResolveTaggedInclude(string rawPathToken, string tag private IEnumerable ResolveFullFileInclude(string rawPathToken, string originalLine) { var rawPath = SubstituteAttributes(rawPathToken); - var resolvedPath = Path.IsPathRooted(rawPath) - ? Path.GetFullPath(rawPath) - : Path.GetFullPath(Path.Combine(_basePath, rawPath)); + var resolvedPath = Path.IsPathRooted(rawPath) ? Path.GetFullPath(rawPath) : Path.GetFullPath(Path.Combine(_basePath, rawPath)); var fileContent = ReadFile(resolvedPath); if (fileContent is null) @@ -543,9 +518,7 @@ private static List ExtractTaggedLines(string content, string tag) continue; // Dedent by the leading whitespace of the tag:: line - result.Add(dedentWidth is > 0 && line.Length >= dedentWidth - ? line[dedentWidth.Value..] - : line); + result.Add(dedentWidth is > 0 && line.Length >= dedentWidth ? line[dedentWidth.Value..] : line); } return result; } @@ -754,9 +727,7 @@ private ListItemNode ParseListItem(TokenType listType) if (cur.Type == listType && cur.Metadata!.Level!.Value > level) { - var nested = listType == TokenType.ListItemUnordered - ? ParseUnorderedList() - : ParseOrderedList(); + var nested = listType == TokenType.ListItemUnordered ? ParseUnorderedList() : ParseOrderedList(); children.Add(nested); continue; } @@ -999,8 +970,10 @@ private IAsciidocNode ParseSeparatedTable(TokenMetadata? blockAttr, string forma private static bool HasHeaderOption(TokenMetadata? blockAttr) { - if (blockAttr?.NamedAttributes?.TryGetValue("options", out var opts) == true - && opts.Contains("header", StringComparison.OrdinalIgnoreCase)) + if ( + blockAttr?.NamedAttributes?.TryGetValue("options", out var opts) == true && + opts.Contains("header", StringComparison.OrdinalIgnoreCase) + ) return true; var content = blockAttr?.Content ?? blockAttr?.BlockStyle ?? ""; @@ -1077,12 +1050,9 @@ private static List SplitTableCells(string content) private TableRowNode BuildTableRow(List cellTexts) { - var cells = cellTexts - .Select(text => new TableCellNode - { - Content = [new ParagraphNode { Inlines = ParseInlines(SubstituteAttributes(text)) }] - }) - .ToList(); + var cells = cellTexts.Select( + text => new TableCellNode { Content = [new ParagraphNode { Inlines = ParseInlines(SubstituteAttributes(text)) }] } + ).ToList(); return new TableRowNode { Cells = cells }; } @@ -1193,19 +1163,17 @@ private IAsciidocNode ParseParagraph() } private static bool IsAdmonitionStyle(string? style) => - style is "note" or "tip" or "warning" or "important" or "caution" or - "NOTE" or "TIP" or "WARNING" or "IMPORTANT" or "CAUTION"; + style is "note" or "tip" or "warning" or "important" or "caution" or "NOTE" or "TIP" or "WARNING" or "IMPORTANT" or "CAUTION"; - private static AdmonitionType ParseAdmonitionType(string style) => - style.ToUpperInvariant() switch - { - "NOTE" => AdmonitionType.Note, - "TIP" => AdmonitionType.Tip, - "WARNING" => AdmonitionType.Warning, - "IMPORTANT" => AdmonitionType.Important, - "CAUTION" => AdmonitionType.Caution, - _ => AdmonitionType.Note - }; + private static AdmonitionType ParseAdmonitionType(string style) => style.ToUpperInvariant() switch + { + "NOTE" => AdmonitionType.Note, + "TIP" => AdmonitionType.Tip, + "WARNING" => AdmonitionType.Warning, + "IMPORTANT" => AdmonitionType.Important, + "CAUTION" => AdmonitionType.Caution, + _ => AdmonitionType.Note + }; private static string? ExtractInlineAnchor(string title) { @@ -1223,9 +1191,7 @@ private static AdmonitionType ParseAdmonitionType(string style) => throw new InvalidOperationException($"Include depth exceeded maximum of {options.MaxIncludeDepth}"); var rawPath = SubstituteAttributes(token.Metadata!.Path!); - var resolvedPath = Path.IsPathRooted(rawPath) - ? Path.GetFullPath(rawPath) - : Path.GetFullPath(Path.Combine(_basePath, rawPath)); + var resolvedPath = Path.IsPathRooted(rawPath) ? Path.GetFullPath(rawPath) : Path.GetFullPath(Path.Combine(_basePath, rawPath)); var content = ReadFile(resolvedPath); if (content is null) { @@ -1417,7 +1383,9 @@ private static string FilterByLines(string content, string linesSpec) var parts = trimmed.Split(".."); var start = int.TryParse(parts[0], out var s) ? s : 1; var endStr = parts.Length > 1 ? parts[1] : ""; - var end = endStr == "-1" || string.IsNullOrEmpty(endStr) ? allLines.Length : int.TryParse(endStr, out var e) ? e : allLines.Length; + var end = endStr == "-1" || string.IsNullOrEmpty(endStr) + ? allLines.Length + : int.TryParse(endStr, out var e) ? e : allLines.Length; for (var i = Math.Max(1, start); i <= Math.Min(end, allLines.Length); i++) result.Add(allLines[i - 1]); @@ -1512,26 +1480,44 @@ private static string ApplyLevelOffset(string content, int offset) return File.Exists(path) ? File.ReadAllText(path) : null; } - [GeneratedRegex( - @"link:([^\[]+)\[([^\]]*)\]|" + // groups 1,2: link - @"<<([^,>]+)(?:,([\s\S]+?))?>>>|" + // groups 3,4: triple-xref (allow newlines in text) - @"<<([^,>]+)(?:,([\s\S]+?))?>>" + "|" + // groups 5,6: xref (allow newlines in text) - @"image:([^\[]+)\[([^\]]*)\]|" + // groups 7,8: image - @"footnote:\[([^\]]*)\]|" + // group 9: footnote - @"pass:\[([^\]]*)\]|" + // group 10: pass:[] passthrough - @"\[([a-zA-Z][a-zA-Z0-9_-]*)\]#([^#]+)#|" + // groups 11,12: [role]#text# - @"\*\*([^\*<]+)\*\*|" + // group 13: unconstrained bold (no < prevents spanning xref markers) - @"\*([^\*<]+)\*|" + // group 14: constrained bold (no < prevents spanning xref markers) - @"_([^_<]+)_|" + // group 15: italic (no < prevents spanning xref markers) - @"(? "text" - @"\s*\+\s*$" // line-break (no capture) - )] + [GeneratedRegex(@"link:([^\[]+)\[([^\]]*)\]|" + + // groups 1,2: link + @"<<([^,>]+)(?:,([\s\S]+?))?>>>|" + + // groups 3,4: triple-xref (allow newlines in text) + @"<<([^,>]+)(?:,([\s\S]+?))?>>" + + "|" + + // groups 5,6: xref (allow newlines in text) + @"image:([^\[]+)\[([^\]]*)\]|" + + // groups 7,8: image + @"footnote:\[([^\]]*)\]|" + + // group 9: footnote + @"pass:\[([^\]]*)\]|" + + // group 10: pass:[] passthrough + @"\[([a-zA-Z][a-zA-Z0-9_-]*)\]#([^#]+)#|" + + // groups 11,12: [role]#text# + @"\*\*([^\*<]+)\*\*|" + + // group 13: unconstrained bold (no < prevents spanning xref markers) + @"\*([^\*<]+)\*|" + + // group 14: constrained bold (no < prevents spanning xref markers) + @"_([^_<]+)_|" + + // group 15: italic (no < prevents spanning xref markers) + @"(? "text" + @"\s*\+\s*$" // line-break (no capture) + )] private static partial Regex InlineCombinedRegex(); [GeneratedRegex(@"\{([a-zA-Z0-9_-]+)\}")] @@ -1550,7 +1536,6 @@ public List ParseInlines(string text) if (text.Contains("<<<<", StringComparison.Ordinal)) text = DoubleXrefRegex().Replace(text, "<<$1$2>>"); - var result = new List(); var lastIndex = 0; @@ -1610,15 +1595,17 @@ private string SubstituteAttributes(string text) if (string.IsNullOrEmpty(text)) return text; - return InlineAttrRefRegex().Replace(text, match => - { - var name = match.Groups[1].Value; - return _attributes.TryGetValue(name, out var value) ? value : match.Value; - }); + return InlineAttrRefRegex().Replace( + text, + match => + { + var name = match.Groups[1].Value; + return _attributes.TryGetValue(name, out var value) ? value : match.Value; + } + ); } - private static string? NullIfEmpty(string value) => - string.IsNullOrEmpty(value) ? null : value; + private static string? NullIfEmpty(string value) => string.IsNullOrEmpty(value) ? null : value; // Collapse newlines and surrounding whitespace in captured inline text to a single space. private static string NormalizeWhitespace(string value) => diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/ConditionalProcessor.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/ConditionalProcessor.cs index 874c5dad72..125429bce8 100644 --- a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/ConditionalProcessor.cs +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/ConditionalProcessor.cs @@ -63,14 +63,17 @@ private static bool IsIncluding(Stack stack) return true; } - private static bool EvaluateCondition(string directive, string condition, IReadOnlyDictionary attributes) => - directive.ToLowerInvariant() switch - { - "ifdef" => EvaluateIfdef(condition, attributes), - "ifndef" => EvaluateIfndef(condition, attributes), - "ifeval" => EvaluateIfeval(condition, attributes), - _ => true - }; + private static bool EvaluateCondition( + string directive, + string condition, + IReadOnlyDictionary attributes + ) => directive.ToLowerInvariant() switch + { + "ifdef" => EvaluateIfdef(condition, attributes), + "ifndef" => EvaluateIfndef(condition, attributes), + "ifeval" => EvaluateIfeval(condition, attributes), + _ => true + }; private static bool EvaluateIfdef(string condition, IReadOnlyDictionary attributes) { @@ -132,11 +135,14 @@ private static bool EvaluateIfeval(string condition, IReadOnlyDictionary attributes) => - AttrRefRegex().Replace(text, match => - { - var name = match.Groups[1].Value; - return attributes.TryGetValue(name, out var value) ? value : match.Value; - }); + AttrRefRegex().Replace( + text, + match => + { + var name = match.Groups[1].Value; + return attributes.TryGetValue(name, out var value) ? value : match.Value; + } + ); private static string UnquoteValue(string value) { diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/MarkdownEmitter.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/MarkdownEmitter.cs index 87e0b8ea4b..a27dad46cc 100644 --- a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/MarkdownEmitter.cs +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/MarkdownEmitter.cs @@ -55,11 +55,9 @@ public partial class MarkdownEmitter(MarkdownEmitterOptions options) public void UpdateAnchorMap(Dictionary slugMap, Dictionary titleMap) => options = options with { AnchorToSlugMap = slugMap, AnchorToTitleMap = titleMap }; - public void UpdatePageSlug(string slug) => - options = options with { PageSlug = slug }; + public void UpdatePageSlug(string slug) => options = options with { PageSlug = slug }; - public void UpdateHeadingBase(int level) => - options = options with { HeadingLevelBase = level }; + public void UpdateHeadingBase(int level) => options = options with { HeadingLevelBase = level }; public string Emit(AsciidocDocument document) { @@ -258,12 +256,11 @@ private void EmitLiteralBlock(LiteralBlockNode literal) WriteLine(); } - private static string MapAdmonitionType(AdmonitionType type) => - type switch - { - AdmonitionType.Caution => "warning", - _ => type.ToString().ToLowerInvariant() - }; + private static string MapAdmonitionType(AdmonitionType type) => type switch + { + AdmonitionType.Caution => "warning", + _ => type.ToString().ToLowerInvariant() + }; private void EmitDirective(string name, string? argument, IReadOnlyList children) { @@ -364,8 +361,7 @@ private static bool IsComplexTable(TableNode table) => table.HeaderRows .Concat(table.BodyRows) .SelectMany(r => r.Cells) - .Any(c => c.ColSpan > 1 || c.RowSpan > 1 || c.Content.Count > 1 - || (c.Content.Count == 1 && c.Content[0] is not ParagraphNode)); + .Any(c => c.ColSpan > 1 || c.RowSpan > 1 || c.Content.Count > 1 || (c.Content.Count == 1 && c.Content[0] is not ParagraphNode)); private void EmitPipeTable(TableNode table) { @@ -614,19 +610,22 @@ private void AppendFootnotes() // Replaces {name} → {{name}} for product-name subs in raw title strings (which // bypass ParseInlines and never hit the AttributeRefInline emission path). private static string SubstituteTitleAttrs(string title) => - AttrRefRegex().Replace(title, m => - SharedAttributes.ProductNames.ContainsKey(m.Groups[1].Value) - ? $"{{{{{m.Groups[1].Value}}}}}" - : m.Value); + AttrRefRegex().Replace( + title, + m => SharedAttributes.ProductNames.ContainsKey(m.Groups[1].Value) ? $"{{{{{m.Groups[1].Value}}}}}" : m.Value + ); // Replaces <> and <> xrefs in raw title strings. private string SubstituteTitleXrefs(string title) => - TitleXrefRegex().Replace(title, m => - { - var anchor = m.Groups[1].Value.Trim(); - var text = m.Groups[2].Success ? m.Groups[2].Value.Trim() : anchor; - if (options.AnchorToSlugMap.TryGetValue(anchor, out var slug)) - return slug == options.PageSlug ? $"[{text}](#{anchor})" : $"[{text}]({slug}.md#{anchor})"; - return $"[{text}](#{anchor})"; - }); + TitleXrefRegex().Replace( + title, + m => + { + var anchor = m.Groups[1].Value.Trim(); + var text = m.Groups[2].Success ? m.Groups[2].Value.Trim() : anchor; + if (options.AnchorToSlugMap.TryGetValue(anchor, out var slug)) + return slug == options.PageSlug ? $"[{text}](#{anchor})" : $"[{text}]({slug}.md#{anchor})"; + return $"[{text}](#{anchor})"; + } + ); } diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/PageChunker.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/PageChunker.cs index b0078d6346..52ea7ace62 100644 --- a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/PageChunker.cs +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/PageChunker.cs @@ -8,12 +8,7 @@ namespace Elastic.LegacyDocs.Migration.Asciidoc; -public record PageOutput( - string Slug, - string Title, - string? NavigationTitle, - string MarkdownContent, - IReadOnlyList Children); +public record PageOutput(string Slug, string Title, string? NavigationTitle, string MarkdownContent, IReadOnlyList Children); public static partial class PageChunker { @@ -29,8 +24,11 @@ public static partial class PageChunker /// is <= chunkLevel + 1 (where chunkLevel == conf.yaml chunk:). ///
public static IReadOnlyList Chunk( - AsciidocDocument document, int chunkLevel, MarkdownEmitter emitter, - Action? onDiagnostic = null) + AsciidocDocument document, + int chunkLevel, + MarkdownEmitter emitter, + Action? onDiagnostic = null + ) { if (chunkLevel <= 0) { @@ -66,8 +64,8 @@ public static IReadOnlyList Chunk( var rootChildren = bookRoot is not null ? bookRoot.Children : document.Children; // Single traversal: build the page tree, anchor maps, and inline content together. - var (rootPageNodes, indexInlineContent) = Traverse( - rootChildren, effectiveChunkLevel, slugMap, titleMap, allocatedSlugs, onDiagnostic); + var (rootPageNodes, indexInlineContent) = + Traverse(rootChildren, effectiveChunkLevel, slugMap, titleMap, allocatedSlugs, onDiagnostic); // Map any inline content at the doc root to the index slug. CollectChildAnchors(indexInlineContent, "index", slugMap, titleMap); @@ -76,9 +74,7 @@ public static IReadOnlyList Chunk( emitter.UpdateAnchorMap(slugMap, titleMap); // Emit the index page using the book root section so its H1 and id are included. - var indexSection = bookRoot is not null - ? bookRoot with { Children = indexInlineContent, Level = 0 } - : null; + var indexSection = bookRoot is not null ? bookRoot with { Children = indexInlineContent, Level = 0 } : null; emitter.UpdatePageSlug("index"); emitter.UpdateHeadingBase(0); var indexContent = indexSection is not null @@ -97,7 +93,8 @@ private sealed record PageNode( string Slug, string? NavigationTitle, List InlineContent, - List ChildPages); + List ChildPages + ); // ── Traversal ───────────────────────────────────────────────────────────── @@ -106,9 +103,13 @@ private sealed record PageNode( /// Pages become their own entries in the nav tree; inline content stays in the parent page body. ///
private static (List Pages, List Inline) Traverse( - IReadOnlyList children, int effectiveChunkLevel, - Dictionary slugMap, Dictionary titleMap, - HashSet allocatedSlugs, Action? onDiagnostic) + IReadOnlyList children, + int effectiveChunkLevel, + Dictionary slugMap, + Dictionary titleMap, + HashSet allocatedSlugs, + Action? onDiagnostic + ) { var pages = new List(); var inline = new List(); @@ -120,14 +121,13 @@ private static (List Pages, List Inline) Traverse( case OpenBlockNode open: { // Transparent container: hoist inner pages up; rewrap remaining inline nodes. - var (innerPages, innerInline) = Traverse( - open.Children, effectiveChunkLevel, slugMap, titleMap, allocatedSlugs, onDiagnostic); + var (innerPages, innerInline) = + Traverse(open.Children, effectiveChunkLevel, slugMap, titleMap, allocatedSlugs, onDiagnostic); pages.AddRange(innerPages); if (innerInline.Count > 0) inline.Add(open with { Children = innerInline }); break; } - case SectionNode section when IsPage(section, effectiveChunkLevel): { // This section becomes its own page. @@ -138,8 +138,8 @@ private static (List Pages, List Inline) Traverse( titleMap[section.Id] = ExtractDisplayTitle(section); } - var (subPages, subInline) = Traverse( - section.Children, effectiveChunkLevel, slugMap, titleMap, allocatedSlugs, onDiagnostic); + var (subPages, subInline) = + Traverse(section.Children, effectiveChunkLevel, slugMap, titleMap, allocatedSlugs, onDiagnostic); // Map all inline sub-content's anchors to this page's slug. CollectChildAnchors(subInline, slug, slugMap, titleMap); @@ -150,11 +150,10 @@ private static (List Pages, List Inline) Traverse( pages.Add(new PageNode(section, slug, navTitleOut, subInline, subPages)); break; } - case SectionNode section: { - var (innerPages, innerInline) = Traverse( - section.Children, effectiveChunkLevel, slugMap, titleMap, allocatedSlugs, onDiagnostic); + var (innerPages, innerInline) = + Traverse(section.Children, effectiveChunkLevel, slugMap, titleMap, allocatedSlugs, onDiagnostic); pages.AddRange(innerPages); if (section.Level == 0 && !section.IsIncludeRoot) { @@ -169,7 +168,6 @@ private static (List Pages, List Inline) Traverse( } break; } - default: inline.Add(child); break; @@ -183,8 +181,11 @@ private static (List Pages, List Inline) Traverse( /// Maps every block anchor and non-page sub-section id to the parent page's slug. private static void CollectChildAnchors( - IReadOnlyList children, string parentSlug, - Dictionary slugMap, Dictionary titleMap) + IReadOnlyList children, + string parentSlug, + Dictionary slugMap, + Dictionary titleMap + ) { foreach (var child in children) { @@ -210,8 +211,7 @@ private static void CollectChildAnchors( // ── Emission ────────────────────────────────────────────────────────────── - private static IReadOnlyList EmitPageNodes( - IReadOnlyList nodes, MarkdownEmitter emitter) + private static IReadOnlyList EmitPageNodes(IReadOnlyList nodes, MarkdownEmitter emitter) { var result = new List(nodes.Count); foreach (var node in nodes) @@ -248,11 +248,9 @@ private static IReadOnlyList EmitPageNodes( /// - Level > 0: page when level <= effectiveChunkLevel (== conf.yaml chunk + 1). ///
private static bool IsPage(SectionNode section, int effectiveChunkLevel) => - !section.IsDiscrete && - (section.Level == 0 ? section.IsIncludeRoot : section.Level <= effectiveChunkLevel); + !section.IsDiscrete && (section.Level == 0 ? section.IsIncludeRoot : section.Level <= effectiveChunkLevel); - private static string AllocateSlug( - SectionNode section, HashSet allocatedSlugs, Action? onDiagnostic) + private static string AllocateSlug(SectionNode section, HashSet allocatedSlugs, Action? onDiagnostic) { var baseSlug = section.Id ?? AutoId(section.Title); if (allocatedSlugs.Add(baseSlug)) @@ -263,8 +261,7 @@ private static string AllocateSlug( var candidate = $"{baseSlug}_{i}"; if (allocatedSlugs.Add(candidate)) { - onDiagnostic?.Invoke( - $"Slug collision for '{baseSlug}' (section '{section.Title}'); using '{candidate}'"); + onDiagnostic?.Invoke($"Slug collision for '{baseSlug}' (section '{section.Title}'); using '{candidate}'"); return candidate; } } diff --git a/src/authoring/Elastic.LegacyDocs.Migration/LatestDocsetGenerator.cs b/src/authoring/Elastic.LegacyDocs.Migration/LatestDocsetGenerator.cs index f05c4ab7e7..716149f7b7 100644 --- a/src/authoring/Elastic.LegacyDocs.Migration/LatestDocsetGenerator.cs +++ b/src/authoring/Elastic.LegacyDocs.Migration/LatestDocsetGenerator.cs @@ -46,8 +46,7 @@ public async Task GenerateAsync(LegacyConf conf, LatestGeneratorOptions options, private async Task ProcessBook(LegacyBook book, LatestGeneratorOptions options, CancellationToken ct) { - var currentBranch = book.Branches.FirstOrDefault(b => b.VersionLabel == book.Current) - ?? new BranchRef(book.Current); + var currentBranch = book.Branches.FirstOrDefault(b => b.VersionLabel == book.Current) ?? new BranchRef(book.Current); var sources = await options.RepoManager.ResolveSourcesAsync(book, currentBranch, ct); if (sources.Count == 0) @@ -68,20 +67,12 @@ private async Task ProcessBook(LegacyBook book, LatestGeneratorOptions options, var basePath = Path.GetDirectoryName(indexPath) ?? primarySource.LocalPath; var parserOptions = new AsciidocParserOptions { - Attributes = new Dictionary - { - ["branch"] = book.Current, - ["doc-tests-src"] = primarySource.LocalPath - } + Attributes = new Dictionary { ["branch"] = book.Current, ["doc-tests-src"] = primarySource.LocalPath } }; var parser = new AsciidocParser(parserOptions); var document = parser.Parse(content, basePath); - var emitterOptions = new MarkdownEmitterOptions - { - BookPrefix = book.Prefix, - Version = book.Current - }; + var emitterOptions = new MarkdownEmitterOptions { BookPrefix = book.Prefix, Version = book.Current }; var emitter = new MarkdownEmitter(emitterOptions); var pages = PageChunker.Chunk(document, book.Chunk, emitter); @@ -107,7 +98,6 @@ private async Task ProcessBook(LegacyBook book, LatestGeneratorOptions options, YamlWriter.WriteDocsetYaml(Path.Combine(docsDir, "docset.yml"), projectName, ["."]); YamlWriter.WriteTocYaml(Path.Combine(docsDir, "toc.yml"), fileEntries); - logger.LogInformation("Wrote {PageCount} pages for {RepoName}/docs (project: {Project})", - pages.Count, repoName, projectName); + logger.LogInformation("Wrote {PageCount} pages for {RepoName}/docs (project: {Project})", pages.Count, repoName, projectName); } } diff --git a/src/authoring/Elastic.LegacyDocs.Migration/LegacyConfParser.cs b/src/authoring/Elastic.LegacyDocs.Migration/LegacyConfParser.cs index 9687257045..6038b526cc 100644 --- a/src/authoring/Elastic.LegacyDocs.Migration/LegacyConfParser.cs +++ b/src/authoring/Elastic.LegacyDocs.Migration/LegacyConfParser.cs @@ -11,9 +11,7 @@ public static class LegacyConfParser { private static readonly IDeserializer RawDeserializer = new DeserializerBuilder().Build(); - private static readonly ISerializer RoundTripSerializer = new SerializerBuilder() - .DisableAliases() - .Build(); + private static readonly ISerializer RoundTripSerializer = new SerializerBuilder().DisableAliases().Build(); private static readonly IDeserializer TypedDeserializer = new DeserializerBuilder() .WithNamingConvention(UnderscoredNamingConvention.Instance) @@ -32,9 +30,7 @@ public static LegacyConf Parse(string yaml) private static LegacyConf Flatten(LegacyConf conf) { - var flatCategories = conf.Contents - .Select(c => c with { Sections = FlattenBooks(c.Sections, "") }) - .ToList(); + var flatCategories = conf.Contents.Select(c => c with { Sections = FlattenBooks(c.Sections, "") }).ToList(); return conf with { Contents = flatCategories }; } @@ -49,9 +45,7 @@ private static List FlattenBooks(List books, string pare { var dir = parentBaseDir.Length > 0 && book.BaseDir.Length > 0 ? $"{parentBaseDir}/{book.BaseDir}" - : parentBaseDir.Length > 0 - ? parentBaseDir - : book.BaseDir; + : parentBaseDir.Length > 0 ? parentBaseDir : book.BaseDir; if (book.Sections.Count > 0) { diff --git a/src/authoring/Elastic.LegacyDocs.Migration/SharedAttributes.cs b/src/authoring/Elastic.LegacyDocs.Migration/SharedAttributes.cs index 9b22ca0730..56607c75b2 100644 --- a/src/authoring/Elastic.LegacyDocs.Migration/SharedAttributes.cs +++ b/src/authoring/Elastic.LegacyDocs.Migration/SharedAttributes.cs @@ -25,7 +25,6 @@ public static class SharedAttributes ["es-serverless"] = "Elasticsearch Serverless", ["obs-serverless"] = "Elastic Observability Serverless", ["sec-serverless"] = "Elastic Security Serverless", - // Core products ["es"] = "Elasticsearch", ["kib"] = "Kibana", @@ -35,7 +34,6 @@ public static class SharedAttributes ["xpack"] = "X-Pack", ["es-sql"] = "Elasticsearch SQL", ["esql"] = "ES|QL", - // Beats ["auditbeat"] = "Auditbeat", ["filebeat"] = "Filebeat", @@ -45,7 +43,6 @@ public static class SharedAttributes ["winlogbeat"] = "Winlogbeat", ["functionbeat"] = "Functionbeat", ["journalbeat"] = "Journalbeat", - // Agents and ingest ["agent"] = "Elastic Agent", ["agents"] = "Elastic Agents", @@ -53,27 +50,22 @@ public static class SharedAttributes ["fleet-server"] = "Fleet Server", ["integrations-server"] = "Integrations Server", ["integrations"] = "Integrations", - // Enterprise Search ["ents"] = "Enterprise Search", ["crawler"] = "Enterprise Search web crawler", - // Observability ["observability"] = "Observability", - // Security ["elastic-sec"] = "Elastic Security", ["elastic-defend"] = "Elastic Defend", ["elastic-endpoint"] = "Elastic Endpoint", ["endpoint-sec"] = "Endpoint Security", - // ML ["ml"] = "machine learning", ["ml-cap"] = "Machine learning", ["ml-init"] = "ML", ["nlp"] = "natural language processing", ["nlp-cap"] = "Natural language processing", - // Features ["security"] = "X-Pack security", ["security-features"] = "security features", @@ -86,7 +78,6 @@ public static class SharedAttributes ["monitoring"] = "X-Pack monitoring", ["reporting"] = "X-Pack reporting", ["graph"] = "X-Pack graph", - // Abbreviations ["ccr"] = "cross-cluster replication", ["ccr-cap"] = "Cross-cluster replication", @@ -114,13 +105,11 @@ public static class SharedAttributes ["infer-cap"] = "Inference", ["search-snaps"] = "searchable snapshots", ["search-snaps-cap"] = "Searchable snapshots", - // Data views ["data-source"] = "data view", ["data-sources"] = "data views", ["data-source-cap"] = "Data view", ["data-sources-cap"] = "Data views", - // Kibana apps ["apm-app"] = "APM app", ["uptime-app"] = "Uptime app", @@ -135,7 +124,6 @@ public static class SharedAttributes ["stack-monitor-app"] = "Stack Monitoring", ["maps-app"] = "Maps", ["data-views-app"] = "Data Views", - // APM agents ["apm-agent"] = "APM agent", ["apm-go-agent"] = "Elastic APM Go agent", @@ -146,7 +134,6 @@ public static class SharedAttributes ["apm-py-agent"] = "Elastic APM Python agent", ["apm-ruby-agent"] = "Elastic APM Ruby agent", ["apm-rum-agent"] = "Elastic APM Real User Monitoring (RUM) JavaScript agent", - // Misc ["k8s"] = "Kubernetes", ["aws"] = "AWS", @@ -155,7 +142,6 @@ public static class SharedAttributes ["data-viz"] = "Data Visualizer", ["feat-imp"] = "feature importance", ["feat-imp-cap"] = "Feature importance", - // Connectors ["sn"] = "ServiceNow", ["sn-itsm"] = "ServiceNow ITSM", diff --git a/src/authoring/Elastic.LegacyDocs.Migration/SourceRepoManager.cs b/src/authoring/Elastic.LegacyDocs.Migration/SourceRepoManager.cs index eb34619a0b..e6ce9d3d76 100644 --- a/src/authoring/Elastic.LegacyDocs.Migration/SourceRepoManager.cs +++ b/src/authoring/Elastic.LegacyDocs.Migration/SourceRepoManager.cs @@ -53,8 +53,7 @@ public static Dictionary> CollectSparsePaths(LegacyConf return result; } - public async Task> ResolveSourcesAsync( - LegacyBook book, BranchRef version, CancellationToken ct = default) + public async Task> ResolveSourcesAsync(LegacyBook book, BranchRef version, CancellationToken ct = default) { var results = new List(); var versionLabel = version.VersionLabel; @@ -105,11 +104,9 @@ private static bool IsExcluded(LegacySource source, string versionLabel) => private static string ResolveBranch(LegacySource source, string versionLabel, string defaultGitBranch) => source.MapBranches.TryGetValue(versionLabel, out var mapped) ? mapped : defaultGitBranch; - private string BareClonePath(string repoName) => - Path.Combine(options.ReposDirectory, $"{repoName}.git"); + private string BareClonePath(string repoName) => Path.Combine(options.ReposDirectory, $"{repoName}.git"); - private string WorktreePath(string repoName, string gitBranch) => - Path.Combine(options.ReposDirectory, repoName, gitBranch); + private string WorktreePath(string repoName, string gitBranch) => Path.Combine(options.ReposDirectory, repoName, gitBranch); private async Task EnsureBareCloneAsync(string repoName, CancellationToken ct) { @@ -158,7 +155,17 @@ private async Task EnsureWorktreeAsync(string repoName, string gitBranch logger.LogInformation("Creating worktree {Repo}@{Branch}...", repoName, resolvedBranch); _ = Directory.CreateDirectory(Path.GetDirectoryName(worktreePath)!); - await ExecGitInAsync(barePath, allowFailure: false, ct, "worktree", "add", "--detach", worktreePath, resolvedBranch, "--no-checkout"); + await ExecGitInAsync( + barePath, + allowFailure: false, + ct, + "worktree", + "add", + "--detach", + worktreePath, + resolvedBranch, + "--no-checkout" + ); await ApplySparseCheckoutAsync(repoName, worktreePath, ct); @@ -204,21 +211,14 @@ private async Task ResolveBranchAsync(string barePath, string gitBranch, private static async Task TryExecGitInAsync(string workingDirectory, CancellationToken ct, params string[] args) { - var arguments = new ExecArguments("git", args) - { - WorkingDirectory = workingDirectory, - ValidExitCodeClassifier = _ => true - }; + var arguments = new ExecArguments("git", args) { WorkingDirectory = workingDirectory, ValidExitCodeClassifier = _ => true }; var exitCode = await Proc.ExecAsync(arguments, ct); return exitCode == 0; } private static async Task ExecGitAsync(CancellationToken ct, params string[] args) { - var arguments = new ExecArguments("git", args) - { - ValidExitCodeClassifier = _ => true - }; + var arguments = new ExecArguments("git", args) { ValidExitCodeClassifier = _ => true }; var exitCode = await Proc.ExecAsync(arguments, ct); if (exitCode != 0) throw new InvalidOperationException($"git {args[0]} failed with exit code {exitCode}"); @@ -226,15 +226,10 @@ private static async Task ExecGitAsync(CancellationToken ct, params string[] arg private static async Task ExecGitInAsync(string workingDirectory, bool allowFailure, CancellationToken ct, params string[] args) { - var arguments = new ExecArguments("git", args) - { - WorkingDirectory = workingDirectory, - ValidExitCodeClassifier = _ => true - }; + var arguments = new ExecArguments("git", args) { WorkingDirectory = workingDirectory, ValidExitCodeClassifier = _ => true }; var exitCode = await Proc.ExecAsync(arguments, ct); if (exitCode != 0 && !allowFailure) - throw new InvalidOperationException( - $"git {args[0]} failed with exit code {exitCode} in {workingDirectory}"); + throw new InvalidOperationException($"git {args[0]} failed with exit code {exitCode} in {workingDirectory}"); } /// Extracts the top-level directory from a source path for sparse checkout. diff --git a/src/infra/docs-lambda-changelog-scrubber/EmfMetricsEmitter.cs b/src/infra/docs-lambda-changelog-scrubber/EmfMetricsEmitter.cs index 7b98796bd5..5c1b0b0958 100644 --- a/src/infra/docs-lambda-changelog-scrubber/EmfMetricsEmitter.cs +++ b/src/infra/docs-lambda-changelog-scrubber/EmfMetricsEmitter.cs @@ -41,15 +41,7 @@ public static void Emit(ReconcileMetrics metrics) Aws = new EmfEnvelope { Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - CloudWatchMetrics = - [ - new EmfMetricDirective - { - Namespace = Namespace, - Dimensions = [[]], - Metrics = MetricDefinitions - } - ] + CloudWatchMetrics = [new EmfMetricDirective { Namespace = Namespace, Dimensions = [[]], Metrics = MetricDefinitions }] }, ObjectReconciles = metrics.ObjectReconciles, ObjectReconcileRetries = metrics.ObjectReconcileRetries, diff --git a/src/infra/docs-lambda-changelog-scrubber/LambdaLoggerFactory.cs b/src/infra/docs-lambda-changelog-scrubber/LambdaLoggerFactory.cs index 69ae19ab78..683cb5107b 100644 --- a/src/infra/docs-lambda-changelog-scrubber/LambdaLoggerFactory.cs +++ b/src/infra/docs-lambda-changelog-scrubber/LambdaLoggerFactory.cs @@ -21,9 +21,7 @@ public void AddProvider(ILoggerProvider provider) // Providers are meaningless here; everything goes to the Lambda logger. } - public void Dispose() - { - } + public void Dispose() { } private sealed class LambdaLoggerAdapter(string categoryName, ILambdaLogger lambdaLogger) : ILogger { @@ -31,7 +29,13 @@ private sealed class LambdaLoggerAdapter(string categoryName, ILambdaLogger lamb public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information; - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) { if (!IsEnabled(logLevel)) return; diff --git a/src/infra/docs-lambda-changelog-scrubber/Program.cs b/src/infra/docs-lambda-changelog-scrubber/Program.cs index 6edd17f89a..524308580f 100644 --- a/src/infra/docs-lambda-changelog-scrubber/Program.cs +++ b/src/infra/docs-lambda-changelog-scrubber/Program.cs @@ -14,13 +14,12 @@ using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Lambda.ChangelogScrubber; -var publicBucketName = Environment.GetEnvironmentVariable("PUBLIC_BUCKET_NAME") - ?? throw new InvalidOperationException("PUBLIC_BUCKET_NAME environment variable is required"); +var publicBucketName = Environment.GetEnvironmentVariable("PUBLIC_BUCKET_NAME") ?? + throw new InvalidOperationException("PUBLIC_BUCKET_NAME environment variable is required"); var allowRepos = BuildAllowlist(); -await LambdaBootstrapBuilder - .Create(Handler, new SourceGeneratorLambdaJsonSerializer()) +await LambdaBootstrapBuilder.Create(Handler, new SourceGeneratorLambdaJsonSerializer()) .Build() .RunAsync(); @@ -28,8 +27,8 @@ await LambdaBootstrapBuilder IReadOnlyList BuildAllowlist() { - using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("assembler.yml") - ?? throw new InvalidOperationException("Embedded assembler.yml not found"); + using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("assembler.yml") ?? + throw new InvalidOperationException("Embedded assembler.yml not found"); using var reader = new StreamReader(stream); var yaml = reader.ReadToEnd(); var assembly = AssemblyConfiguration.Deserialize(yaml, skipPrivateRepositories: false); @@ -40,16 +39,13 @@ IReadOnlyList BuildAllowlist() // run the state-driven reconcile, translate the failed message ids back out, emit metrics. async Task Handler(SQSEvent ev, ILambdaContext context) { - var region = Amazon.RegionEndpoint.GetBySystemName( - Environment.GetEnvironmentVariable("AWS_REGION") ?? "us-east-1"); + var region = Amazon.RegionEndpoint.GetBySystemName(Environment.GetEnvironmentVariable("AWS_REGION") ?? "us-east-1"); var credentials = new Amazon.Runtime.EnvironmentVariablesAWSCredentials(); - using var s3Client = new AmazonS3Client(credentials, new AmazonS3Config - { - RegionEndpoint = region, - Timeout = TimeSpan.FromSeconds(10), - MaxErrorRetry = 2 - }); + using var s3Client = new AmazonS3Client( + credentials, + new AmazonS3Config { RegionEndpoint = region, Timeout = TimeSpan.FromSeconds(10), MaxErrorRetry = 2 } + ); using var logFactory = new LambdaLoggerFactory(context.Logger); var metrics = new ReconcileMetrics(); @@ -63,8 +59,7 @@ async Task Handler(SQSEvent ev, ILambdaContext context) EmfMetricsEmitter.Emit(metrics); - var response = new SQSBatchResponse( - [.. failedIds.Select(id => new SQSBatchResponse.BatchItemFailure { ItemIdentifier = id })]); + var response = new SQSBatchResponse([.. failedIds.Select(id => new SQSBatchResponse.BatchItemFailure { ItemIdentifier = id })]); if (failedIds.Count > 0) context.Logger.LogInformation("Failed {FailedCount} of {TotalCount} messages", failedIds.Count, ev.Records.Count); return response; diff --git a/src/infra/docs-lambda-index-publisher/Program.cs b/src/infra/docs-lambda-index-publisher/Program.cs index f67b83dde4..6c04271e19 100644 --- a/src/infra/docs-lambda-index-publisher/Program.cs +++ b/src/infra/docs-lambda-index-publisher/Program.cs @@ -49,10 +49,7 @@ static async Task Handler(SQSEvent ev, ILambdaContext context) { // Add failed message identifier to the batchItemFailures list context.Logger.LogWarning(e, "Failed to process message {MessageId}", message.MessageId); - batchItemFailures.Add(new SQSBatchResponse.BatchItemFailure - { - ItemIdentifier = message.MessageId - }); + batchItemFailures.Add(new SQSBatchResponse.BatchItemFailure { ItemIdentifier = message.MessageId }); } } try @@ -60,7 +57,11 @@ static async Task Handler(SQSEvent ev, ILambdaContext context) await linkIndexReaderWriter.SaveRegistry(linkRegistry); var response = new SQSBatchResponse(batchItemFailures); if (batchItemFailures.Count > 0) - context.Logger.LogInformation("Failed to process {batchItemFailuresCount} of {allMessagesCount} messages. Returning them to the queue.", batchItemFailures.Count, ev.Records.Count); + context.Logger.LogInformation( + "Failed to process {batchItemFailuresCount} of {allMessagesCount} messages. Returning them to the queue.", + batchItemFailures.Count, + ev.Records.Count + ); var jsonStr = JsonSerializer.Serialize(response, SerializerContext.Default.SQSBatchResponse); context.Logger.LogInformation(jsonStr); return response; @@ -69,12 +70,16 @@ static async Task Handler(SQSEvent ev, ILambdaContext context) { // If we fail to update the link index, we need to return all messages to the queue // so that they can be retried later. - context.Logger.LogError("Failed to update {bucketName}/{indexFile}. Returning all {recordCount} messages to the queue.", bucketName, indexFile, ev.Records.Count); + context.Logger.LogError( + "Failed to update {bucketName}/{indexFile}. Returning all {recordCount} messages to the queue.", + bucketName, + indexFile, + ev.Records.Count + ); context.Logger.LogError(ex, ex.Message); - var response = new SQSBatchResponse(ev.Records.Select(r => new SQSBatchResponse.BatchItemFailure - { - ItemIdentifier = r.MessageId - }).ToList()); + var response = new SQSBatchResponse( + ev.Records.Select(r => new SQSBatchResponse.BatchItemFailure { ItemIdentifier = r.MessageId }).ToList() + ); var jsonStr = JsonSerializer.Serialize(response, SerializerContext.Default.SQSBatchResponse); context.Logger.LogInformation(jsonStr); return response; @@ -98,15 +103,20 @@ static LinkRegistryEntry ConvertToLinkIndexEntry(S3EventNotification.S3EventNoti }; } -static async Task> GetS3RecordLinkReferenceTuples(ILinkIndexReaderWriter linkIndexReaderWriter, - SQSEvent.SQSMessage message) +static async Task> GetS3RecordLinkReferenceTuples( + ILinkIndexReaderWriter linkIndexReaderWriter, + SQSEvent.SQSMessage message +) { var s3Event = S3EventNotification.ParseJson(message.Body); var recordLinkReferenceTuples = new ConcurrentBag<(S3EventNotification.S3EventNotificationRecord, RepositoryLinks)>(); - await Parallel.ForEachAsync(s3Event.Records, async (record, ctx) => - { - var linkReference = await linkIndexReaderWriter.GetRepositoryLinks(record.S3.Object.Key, ctx); - recordLinkReferenceTuples.Add((record, linkReference)); - }); + await Parallel.ForEachAsync( + s3Event.Records, + async (record, ctx) => + { + var linkReference = await linkIndexReaderWriter.GetRepositoryLinks(record.S3.Object.Key, ctx); + recordLinkReferenceTuples.Add((record, linkReference)); + } + ); return recordLinkReferenceTuples; } diff --git a/src/infra/docs-lambda-openapi-index/Program.cs b/src/infra/docs-lambda-openapi-index/Program.cs index 89d026ee2c..575c131a10 100644 --- a/src/infra/docs-lambda-openapi-index/Program.cs +++ b/src/infra/docs-lambda-openapi-index/Program.cs @@ -15,8 +15,8 @@ const string bucketName = "elastic-docs-openapi-specs"; -var distributionId = Environment.GetEnvironmentVariable("CLOUDFRONT_DISTRIBUTION_ID") - ?? throw new InvalidOperationException("CLOUDFRONT_DISTRIBUTION_ID environment variable is required"); +var distributionId = Environment.GetEnvironmentVariable("CLOUDFRONT_DISTRIBUTION_ID") ?? + throw new InvalidOperationException("CLOUDFRONT_DISTRIBUTION_ID environment variable is required"); // Built once per execution environment, so credential and endpoint resolution happens during Lambda's // init phase instead of on every invocation. @@ -39,7 +39,11 @@ async Task Handler(SQSEvent ev, ILambdaContext context) var objectKeys = ExtractObjectKeys(ev); var ignoredKeys = await publisher.RefreshAsync(CancellationToken.None).ConfigureAwait(false); if (ignoredKeys.Count > 0) - context.Logger.LogWarning("Ignored {ignoredCount} object key(s) not shaped as org/repo/version/file: {ignoredKeys}", ignoredKeys.Count, string.Join(", ", ignoredKeys)); + context.Logger.LogWarning( + "Ignored {ignoredCount} object key(s) not shaped as org/repo/version/file: {ignoredKeys}", + ignoredKeys.Count, + string.Join(", ", ignoredKeys) + ); var paths = OpenApiInvalidationPaths.Build(objectKeys); await invalidator.InvalidateAsync(paths, context.AwsRequestId, CancellationToken.None).ConfigureAwait(false); @@ -49,15 +53,24 @@ async Task Handler(SQSEvent ev, ILambdaContext context) bucketName, VersionIndexPublisher.IndexKey, paths.Count, - ev.Records.Count); + ev.Records.Count + ); return new SQSBatchResponse([]); } catch (Exception ex) { // Return every message in the batch to the queue: the rebuild reads the whole bucket, so // retrying only some of the batch would just repeat the exact same LIST-and-rebuild anyway. - context.Logger.LogError(ex, "Failed to refresh {bucketName}/{indexKey}. Returning all {recordCount} message(s) to the queue.", bucketName, VersionIndexPublisher.IndexKey, ev.Records.Count); - return new SQSBatchResponse(ev.Records.Select(r => new SQSBatchResponse.BatchItemFailure { ItemIdentifier = r.MessageId }).ToList()); + context.Logger.LogError( + ex, + "Failed to refresh {bucketName}/{indexKey}. Returning all {recordCount} message(s) to the queue.", + bucketName, + VersionIndexPublisher.IndexKey, + ev.Records.Count + ); + return new SQSBatchResponse( + ev.Records.Select(r => new SQSBatchResponse.BatchItemFailure { ItemIdentifier = r.MessageId }).ToList() + ); } } diff --git a/src/services/Elastic.Changelog/AllowlistIdentity/ScrubberAllowlistIdentity.cs b/src/services/Elastic.Changelog/AllowlistIdentity/ScrubberAllowlistIdentity.cs index 68191dba60..ebefb3f3de 100644 --- a/src/services/Elastic.Changelog/AllowlistIdentity/ScrubberAllowlistIdentity.cs +++ b/src/services/Elastic.Changelog/AllowlistIdentity/ScrubberAllowlistIdentity.cs @@ -63,7 +63,9 @@ public sealed partial record ScrubberAllowlistIdentity public void Validate(IList problems) { if (SchemaVersion != CurrentSchemaVersion) - problems.Add($"Unsupported allowlist identity schema version {SchemaVersion}; this reader understands version {CurrentSchemaVersion}."); + problems.Add( + $"Unsupported allowlist identity schema version {SchemaVersion}; this reader understands version {CurrentSchemaVersion}." + ); if (!string.Equals(Artifact, ArtifactKind, StringComparison.Ordinal)) problems.Add($"Expected artifact '{ArtifactKind}' but found '{Artifact}'."); if (string.IsNullOrWhiteSpace(AllowlistSha256) || !Sha256Format().IsMatch(AllowlistSha256)) @@ -76,7 +78,11 @@ public void Validate(IList problems) /// Parses an identity document from JSON. Returns false with the reasons in /// when the document is malformed or fails validation. /// - public static bool TryParse(string json, [NotNullWhen(true)] out ScrubberAllowlistIdentity? identity, out IReadOnlyList problems) + public static bool TryParse( + string json, + [NotNullWhen(true)] out ScrubberAllowlistIdentity? identity, + out IReadOnlyList problems + ) { var found = new List(); identity = null; diff --git a/src/services/Elastic.Changelog/AllowlistIdentity/ScrubberAllowlistIdentityService.cs b/src/services/Elastic.Changelog/AllowlistIdentity/ScrubberAllowlistIdentityService.cs index 8771c908d2..94b29c7e00 100644 --- a/src/services/Elastic.Changelog/AllowlistIdentity/ScrubberAllowlistIdentityService.cs +++ b/src/services/Elastic.Changelog/AllowlistIdentity/ScrubberAllowlistIdentityService.cs @@ -41,7 +41,8 @@ public record ResolvedScrubberAllowlist public string? LocalSha256 { get; init; } /// Whether the local allowlist matches the deployed one; null when no local path was given. - public bool? MatchesLocal => LocalSha256 is null ? null : string.Equals(LocalSha256, Identity.AllowlistSha256, StringComparison.Ordinal); + public bool? MatchesLocal => + LocalSha256 is null ? null : string.Equals(LocalSha256, Identity.AllowlistSha256, StringComparison.Ordinal); } /// @@ -69,7 +70,8 @@ IFileSystem fileSystem public async Task ResolveDeployedAsync( IDiagnosticsCollector collector, ResolveScrubberAllowlistArguments args, - Cancel ctx = default) + Cancel ctx = default + ) { var located = await LocateIdentityAssetAsync(collector, args, ctx); if (located is null) @@ -79,8 +81,10 @@ IFileSystem fileSystem var json = await releaseService.DownloadAssetTextAsync(asset, ctx); if (json is null) { - collector.EmitError(string.Empty, - $"Failed to download release asset '{asset.Name}' from {args.Owner}/{args.Repo}@{release.TagName}."); + collector.EmitError( + string.Empty, + $"Failed to download release asset '{asset.Name}' from {args.Owner}/{args.Repo}@{release.TagName}." + ); return null; } @@ -92,21 +96,22 @@ IFileSystem fileSystem } var localSha = ComputeLocalSha256(collector, args.AssemblerPath); - var resolved = new ResolvedScrubberAllowlist - { - Identity = identity, - ReleaseTag = release.TagName, - LocalSha256 = localSha - }; + var resolved = new ResolvedScrubberAllowlist { Identity = identity, ReleaseTag = release.TagName, LocalSha256 = localSha }; - _logger.LogInformation("Deployed scrubber allowlist: {Sha256} (commit {Commit}, release {Tag})", - identity.AllowlistSha256, identity.DeploymentCommit, release.TagName); + _logger.LogInformation( + "Deployed scrubber allowlist: {Sha256} (commit {Commit}, release {Tag})", + identity.AllowlistSha256, + identity.DeploymentCommit, + release.TagName + ); if (resolved.MatchesLocal == false) { - collector.EmitWarning(string.Empty, + collector.EmitWarning( + string.Empty, $"Local assembler.yml ({localSha}) differs from the deployed scrubber allowlist ({identity.AllowlistSha256}, release {release.TagName}). " + - "Links must be validated against the deployed allowlist, not the local checkout."); + "Links must be validated against the deployed allowlist, not the local checkout." + ); } else if (resolved.MatchesLocal == true) { @@ -119,24 +124,29 @@ IFileSystem fileSystem private async Task<(GitHubReleaseInfo Release, GitHubReleaseAsset Asset)?> LocateIdentityAssetAsync( IDiagnosticsCollector collector, ResolveScrubberAllowlistArguments args, - Cancel ctx) + Cancel ctx + ) { if (!string.IsNullOrWhiteSpace(args.Tag)) { var release = await releaseService.FetchReleaseAsync(args.Owner, args.Repo, args.Tag, ctx); if (release is null) { - collector.EmitError(string.Empty, - $"Release '{args.Tag}' was not found on {args.Owner}/{args.Repo}. Ensure the tag exists and credentials are set."); + collector.EmitError( + string.Empty, + $"Release '{args.Tag}' was not found on {args.Owner}/{args.Repo}. Ensure the tag exists and credentials are set." + ); return null; } var asset = FindIdentityAsset(release); if (asset is null) { - collector.EmitError(string.Empty, + collector.EmitError( + string.Empty, $"Release {args.Owner}/{args.Repo}@{release.TagName} does not carry the '{ScrubberAllowlistIdentity.AssetName}' asset: " + - "either the release predates allowlist identity publication, or its scrubber deploy never completed."); + "either the release predates allowlist identity publication, or its scrubber deploy never completed." + ); return null; } @@ -152,9 +162,11 @@ IFileSystem fileSystem _logger.LogDebug("Release {Tag} has no allowlist identity asset; looking further back", release.TagName); } - collector.EmitError(string.Empty, + collector.EmitError( + string.Empty, $"No release among the latest {ReleaseLookback} on {args.Owner}/{args.Repo} carries the '{ScrubberAllowlistIdentity.AssetName}' asset, " + - "so the deployed scrubber allowlist identity cannot be resolved. A backfill plan cannot be approved without it."); + "so the deployed scrubber allowlist identity cannot be resolved. A backfill plan cannot be approved without it." + ); return null; } diff --git a/src/services/Elastic.Changelog/Bundling/BundleBuilder.cs b/src/services/Elastic.Changelog/Bundling/BundleBuilder.cs index 7e4b4e03c8..07ddb0aa0c 100644 --- a/src/services/Elastic.Changelog/Bundling/BundleBuilder.cs +++ b/src/services/Elastic.Changelog/Bundling/BundleBuilder.cs @@ -29,7 +29,8 @@ public BundleBuildResult BuildBundle( IReadOnlyList? outputProducts, string? repo = null, string? owner = null, - HashSet? hideFeatures = null) + HashSet? hideFeatures = null + ) { // Build products list var bundledProducts = BuildProducts(collector, entries, outputProducts, repo, owner); @@ -39,11 +40,7 @@ public BundleBuildResult BuildBundle( if (bundledEntries == null) { - return new BundleBuildResult - { - IsValid = false, - Data = null - }; + return new BundleBuildResult { IsValid = false, Data = null }; } var bundledData = new Bundle @@ -53,11 +50,7 @@ public BundleBuildResult BuildBundle( Entries = bundledEntries }; - return new BundleBuildResult - { - IsValid = true, - Data = bundledData - }; + return new BundleBuildResult { IsValid = true, Data = bundledData }; } private static List BuildProducts( @@ -65,25 +58,29 @@ private static List BuildProducts( IReadOnlyList entries, IReadOnlyList? outputProducts, string? repo, - string? owner) + string? owner + ) { List bundledProducts; if (outputProducts is { Count: > 0 }) { - bundledProducts = outputProducts - .OrderBy(p => p.Product) - .ThenBy(p => p.Target ?? string.Empty) - .ThenBy(p => p.Lifecycle ?? string.Empty) - .Select(p => new BundledProduct - { - ProductId = p.Product ?? "", - Target = p.Target == "*" ? null : p.Target, - Lifecycle = ParseLifecycle(p.Lifecycle == "*" ? null : p.Lifecycle), - Repo = repo, - Owner = owner - }) - .ToList(); + bundledProducts = + outputProducts.OrderBy(p => p.Product) + .ThenBy(p => p.Target ?? string.Empty) + .ThenBy(p => p.Lifecycle ?? string.Empty) + .Select( + p => + new BundledProduct + { + ProductId = p.Product ?? "", + Target = p.Target == "*" ? null : p.Target, + Lifecycle = ParseLifecycle(p.Lifecycle == "*" ? null : p.Lifecycle), + Repo = repo, + Owner = owner + } + ) + .ToList(); } else if (entries.Count > 0) { @@ -99,17 +96,21 @@ private static List BuildProducts( } } - bundledProducts = productVersions - .OrderBy(pv => pv.product) - .ThenBy(pv => pv.version) - .ThenBy(pv => pv.lifecycle?.ToStringFast(true) ?? string.Empty) - .Select(pv => new BundledProduct( - pv.product, - string.IsNullOrWhiteSpace(pv.version) ? null : pv.version, - pv.lifecycle, - repo, - owner)) - .ToList(); + bundledProducts = + productVersions.OrderBy(pv => pv.product) + .ThenBy(pv => pv.version) + .ThenBy(pv => pv.lifecycle?.ToStringFast(true) ?? string.Empty) + .Select( + pv => + new BundledProduct( + pv.product, + string.IsNullOrWhiteSpace(pv.version) ? null : pv.version, + pv.lifecycle, + repo, + owner + ) + ) + .ToList(); } else bundledProducts = []; @@ -128,7 +129,10 @@ private static List BuildProducts( target = $"{target} {p.Lifecycle.Value.ToStringFast(true)}"; return target; }).ToList(); - collector.EmitWarning(string.Empty, $"Product '{productGroup.Key}' has multiple targets in bundle: {string.Join(", ", targets)}"); + collector.EmitWarning( + string.Empty, + $"Product '{productGroup.Key}' has multiple targets in bundle: {string.Join(", ", targets)}" + ); } return bundledProducts; @@ -139,14 +143,10 @@ private static List BuildProducts( if (string.IsNullOrEmpty(value)) return null; - return LifecycleExtensions.TryParse(value, out var result, ignoreCase: true, allowMatchingMetadataAttribute: true) - ? result - : null; + return LifecycleExtensions.TryParse(value, out var result, ignoreCase: true, allowMatchingMetadataAttribute: true) ? result : null; } - private static List? BuildResolvedEntries( - IDiagnosticsCollector collector, - IReadOnlyList entries) + private static List? BuildResolvedEntries(IDiagnosticsCollector collector, IReadOnlyList entries) { var resolvedEntries = new List(); var hasInvalidEntries = false; @@ -161,11 +161,7 @@ private static List BuildProducts( var bundledEntry = entry.Data.ToBundledEntry() with { - File = new BundledFile - { - Name = entry.FileName, - Checksum = entry.Checksum - } + File = new BundledFile { Name = entry.FileName, Checksum = entry.Checksum } }; resolvedEntries.Add(bundledEntry); } diff --git a/src/services/Elastic.Changelog/Bundling/BundleDescriptionSubstitution.cs b/src/services/Elastic.Changelog/Bundling/BundleDescriptionSubstitution.cs index ec52f02b07..82c955e90a 100644 --- a/src/services/Elastic.Changelog/Bundling/BundleDescriptionSubstitution.cs +++ b/src/services/Elastic.Changelog/Bundling/BundleDescriptionSubstitution.cs @@ -27,7 +27,8 @@ public static string SubstitutePlaceholders( string? lifecycle, string? owner, string? repo, - bool validateResolvable = false) + bool validateResolvable = false + ) { if (string.IsNullOrEmpty(description)) return description; @@ -48,8 +49,7 @@ public static string SubstitutePlaceholders( throw new InvalidOperationException($"Cannot resolve placeholders: {string.Join(", ", missingValues)}"); } - return description - .Replace("{version}", version ?? string.Empty) + return description.Replace("{version}", version ?? string.Empty) .Replace("{lifecycle}", lifecycle ?? string.Empty) .Replace("{owner}", owner ?? string.Empty) .Replace("{repo}", repo ?? string.Empty); diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs index 6cdd7f54ff..ad52d6b0bc 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs @@ -57,7 +57,8 @@ public record AmendBundleArguments public partial class ChangelogBundleAmendService( ILoggerFactory logFactory, IChangelogFileSystem fileSystem, - IConfigurationContext? configurationContext = null) : IService + IConfigurationContext? configurationContext = null +) : IService { /// /// UTF-8 encoding without BOM for writing YAML files. @@ -83,10 +84,7 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle if (!_fileSystem.File.Exists(input.BundlePath)) { var currentDir = _fileSystem.Directory.GetCurrentDirectory(); - collector.EmitError( - input.BundlePath, - $"Bundle file does not exist. Current directory: {currentDir}" - ); + collector.EmitError(input.BundlePath, $"Bundle file does not exist. Current directory: {currentDir}"); return false; } @@ -104,17 +102,11 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle if (removeFilePaths == null) return false; - var (parentOk, parentBundle) = await TryDeserializeParentBundleAsync( - input.BundlePath, - collector, - ctx); + var (parentOk, parentBundle) = await TryDeserializeParentBundleAsync(input.BundlePath, collector, ctx); if (!parentOk || parentBundle == null) return false; - var (amendsOk, existingAmendBundles) = await LoadExistingAmendBundlesAsync( - input.BundlePath, - collector, - ctx); + var (amendsOk, existingAmendBundles) = await LoadExistingAmendBundlesAsync(input.BundlePath, collector, ctx); if (!amendsOk) return false; @@ -124,13 +116,8 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle var excludeEntries = new List(); foreach (var removeFilePath in removeFilePaths!) { - var exclusion = await BuildExclusionEntryAsync( - collector, - removeFilePath, - effectiveEntries, - appliedExclusionKeys, - input.Force, - ctx); + var exclusion = + await BuildExclusionEntryAsync(collector, removeFilePath, effectiveEntries, appliedExclusionKeys, input.Force, ctx); if (exclusion == null) return false; if (exclusion is RemoveExclusionResult.Skip) @@ -158,14 +145,17 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle { var owner = parentBundle.Products.Count > 0 ? parentBundle.Products[0].Owner ?? "elastic" : "elastic"; var repo = parentBundle.Products.Count > 0 ? parentBundle.Products[0].Repo : null; - if (!LinkAllowlistSanitizer.TryApplyBundle( - collector, - parentBundle, - linkAllowRepos!, - owner, - repo, - out _, - out var parentHadAllowlistChanges)) + if ( + !LinkAllowlistSanitizer.TryApplyBundle( + collector, + parentBundle, + linkAllowRepos!, + owner, + repo, + out _, + out var parentHadAllowlistChanges + ) + ) return false; if (parentHadAllowlistChanges) @@ -173,8 +163,9 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle collector.EmitError( string.Empty, "bundle.link_allow_repos requires the parent bundle to already reflect filtered PR/issue references. " + - "Re-create the parent bundle with the same bundle.link_allow_repos, " + - "or remove bundle.link_allow_repos for amend."); + "Re-create the parent bundle with the same bundle.link_allow_repos, " + + "or remove bundle.link_allow_repos for amend." + ); return false; } } @@ -199,7 +190,8 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle _logger.LogInformation( "Dry run: would exclude {ExcludeCount} and add {AddCount} entries", excludeEntries.Count, - entries.Count); + entries.Count + ); return true; } @@ -210,17 +202,13 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle "Creating amend file: {AmendFilePath} (exclude={ExcludeCount}, add={AddCount})", amendFilePath, excludeEntries.Count, - entries.Count); + entries.Count + ); // Copy the parent's complete products (target, repo, owner) so the amend is self-contained: // upload destination discovery, the registry's per-product target, and :version:-filtered // CDN fetches all derive from a bundle file's own products. - var amendBundle = new Bundle - { - Products = parentBundle.Products, - ExcludeEntries = excludeEntries, - Entries = entries - }; + var amendBundle = new Bundle { Products = parentBundle.Products, ExcludeEntries = excludeEntries, Entries = entries }; var bundleForWrite = amendBundle; if (entries.Count > 0 && linkAllowRepos != null) @@ -228,14 +216,7 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle var owner = parentBundle.Products.Count > 0 ? parentBundle.Products[0].Owner ?? "elastic" : "elastic"; var repo = parentBundle.Products.Count > 0 ? parentBundle.Products[0].Repo : null; - if (!LinkAllowlistSanitizer.TryApplyBundle( - collector, - amendBundle, - linkAllowRepos, - owner, - repo, - out var sanitized, - out _)) + if (!LinkAllowlistSanitizer.TryApplyBundle(collector, amendBundle, linkAllowRepos, owner, repo, out var sanitized, out _)) return false; bundleForWrite = sanitized; @@ -251,7 +232,8 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle { collector.EmitWarning( string.Empty, - $"Could not load assembler.yml for bundle.link_allow_repos diagnostics: {ex.Message}"); + $"Could not load assembler.yml for bundle.link_allow_repos diagnostics: {ex.Message}" + ); } } } @@ -268,7 +250,8 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle "Created amend file: {AmendFilePath} with {ExcludeCount} exclusions and {AddCount} additions", amendFilePath, excludeEntries.Count, - entries.Count); + entries.Count + ); return true; } @@ -284,10 +267,7 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle } } - private List? ValidateInputFiles( - IDiagnosticsCollector collector, - IReadOnlyList files, - string optionName) + private List? ValidateInputFiles(IDiagnosticsCollector collector, IReadOnlyList files, string optionName) { if (files.Count == 0) return []; @@ -301,8 +281,8 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle collector.EmitError( file, $"File does not exist. Current directory: {currentDir}. " + - $"Tip: Repeat {optionName} for each file, or use comma-separated values (e.g., {optionName} \"file1.yaml,file2.yaml\"). " + - "Paths support tilde (~) expansion and can be relative or absolute." + $"Tip: Repeat {optionName} for each file, or use comma-separated values (e.g., {optionName} \"file1.yaml,file2.yaml\"). " + + "Paths support tilde (~) expansion and can be relative or absolute." ); return null; } @@ -315,7 +295,8 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle private async Task<(bool Ok, List Bundles)> LoadExistingAmendBundlesAsync( string bundlePath, IDiagnosticsCollector collector, - Cancel ctx) + Cancel ctx + ) { var amendPaths = DiscoverAmendFiles(_fileSystem, bundlePath); var amendBundles = new List(); @@ -328,10 +309,7 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle } catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or ThreadAbortException)) { - collector.EmitError( - amendPath, - $"Failed to deserialize amend file: {ex.Message}", - ex); + collector.EmitError(amendPath, $"Failed to deserialize amend file: {ex.Message}", ex); return (false, []); } } @@ -344,56 +322,38 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle IReadOnlyList effectiveEntries, HashSet appliedExclusionKeys, bool force, - Cancel ctx) + Cancel ctx + ) { var fileName = _fileSystem.Path.GetFileName(removeFilePath); var content = await _fileSystem.File.ReadAllTextAsync(removeFilePath, ctx); var fileChecksum = ChangelogBundlingService.ComputeSha1(content); - var strictExclusion = new BundledEntry - { - File = new BundledFile - { - Name = fileName, - Checksum = fileChecksum - } - }; + var strictExclusion = new BundledEntry { File = new BundledFile { Name = fileName, Checksum = fileChecksum } }; var exclusionKey = BundleAmendMerger.BuildExclusionKey(strictExclusion); if (appliedExclusionKeys.Contains(exclusionKey)) { - collector.EmitWarning( - removeFilePath, - $"Changelog '{fileName}' is already excluded by a prior amend file; skipping."); + collector.EmitWarning(removeFilePath, $"Changelog '{fileName}' is already excluded by a prior amend file; skipping."); return RemoveExclusionResult.Skip.Instance; } - var strictMatches = effectiveEntries - .Where(entry => BundleAmendMerger.EntryMatchesExclusion(entry, strictExclusion)) - .ToList(); + var strictMatches = effectiveEntries.Where(entry => BundleAmendMerger.EntryMatchesExclusion(entry, strictExclusion)).ToList(); var matchedEntry = strictMatches.Count > 0 ? strictMatches[0] : null; if (matchedEntry == null) { - var nameOnlyExclusion = new BundledEntry - { - File = new BundledFile - { - Name = fileName, - Checksum = string.Empty - } - }; + var nameOnlyExclusion = new BundledEntry { File = new BundledFile { Name = fileName, Checksum = string.Empty } }; - var nameMatches = effectiveEntries - .Where(entry => BundleAmendMerger.EntryMatchesExclusion(entry, nameOnlyExclusion)) - .ToList(); + var nameMatches = effectiveEntries.Where(entry => BundleAmendMerger.EntryMatchesExclusion(entry, nameOnlyExclusion)).ToList(); if (nameMatches.Count == 0) { collector.EmitError( removeFilePath, - $"Changelog '{fileName}' was not found in the effective bundle (parent plus existing amend files)."); + $"Changelog '{fileName}' was not found in the effective bundle (parent plus existing amend files)." + ); return null; } @@ -402,7 +362,8 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle collector.EmitError( removeFilePath, $"Bundle contains '{fileName}' but with a different checksum than the file on disk. " + - "Re-create the bundle or use --force to remove by file name only."); + "Re-create the bundle or use --force to remove by file name only." + ); return null; } @@ -410,14 +371,7 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle } var exclusionChecksum = matchedEntry.File?.Checksum ?? fileChecksum; - return new RemoveExclusionResult.Add(new BundledEntry - { - File = new BundledFile - { - Name = fileName, - Checksum = exclusionChecksum - } - }); + return new RemoveExclusionResult.Add(new BundledEntry { File = new BundledFile { Name = fileName, Checksum = exclusionChecksum } }); } private abstract record RemoveExclusionResult @@ -433,7 +387,8 @@ private Skip() { } private async Task<(bool Ok, Bundle? Bundle)> TryDeserializeParentBundleAsync( string bundlePath, IDiagnosticsCollector collector, - Cancel ctx) + Cancel ctx + ) { try { @@ -443,10 +398,7 @@ private Skip() { } } catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException)) { - collector.EmitError( - bundlePath, - $"Failed to parse parent bundle YAML: {ex.Message}", - ex); + collector.EmitError(bundlePath, $"Failed to parse parent bundle YAML: {ex.Message}", ex); return (false, null); } } @@ -458,8 +410,7 @@ private int GetNextAmendNumber(string bundlePath) var existingAmendFiles = _fileSystem.Directory.GetFiles(directory, $"{baseName}.amend-*.y*ml"); - var maxNumber = existingAmendFiles - .Select(file => AmendFileRegex().Match(file)) + var maxNumber = existingAmendFiles.Select(file => AmendFileRegex().Match(file)) .Where(match => match.Success && int.TryParse(match.Groups[1].Value, out _)) .Select(match => int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture)) .DefaultIfEmpty(0) @@ -477,10 +428,7 @@ private string GenerateAmendFilePath(string bundlePath, int amendNumber) return _fileSystem.Path.Join(directory, $"{baseName}.amend-{amendNumber}{extension}"); } - private async Task LoadChangelogFileAsync( - IDiagnosticsCollector collector, - string filePath, - Cancel ctx) + private async Task LoadChangelogFileAsync(IDiagnosticsCollector collector, string filePath, Cancel ctx) { try { @@ -494,11 +442,7 @@ private string GenerateAmendFilePath(string bundlePath, int amendNumber) return new BundledEntry { - File = new BundledFile - { - Name = fileName, - Checksum = checksum - }, + File = new BundledFile { Name = fileName, Checksum = checksum }, Type = entry.Type, Title = entry.Title, Products = entry.Products, @@ -531,7 +475,8 @@ public static IReadOnlyList DiscoverAmendFiles(IFileSystem fileSystem, s if (!fileSystem.Directory.Exists(directory)) return []; - var amendFiles = fileSystem.Directory.GetFiles(directory, $"{baseName}.amend-*.y*ml") + var amendFiles = fileSystem.Directory + .GetFiles(directory, $"{baseName}.amend-*.y*ml") .OrderBy(BundleAmendMerger.GetAmendFileNumber) .ToList(); diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index c9b9e65bda..464bdcdcff 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -170,8 +170,8 @@ public partial class ChangelogBundlingService( IGitHubReleaseService? releaseService = null, CdnChangelogEntryFetcher? entryFetcher = null, IGitHubPrService? prService = null, - IGitHubCommitRangeService? commitRangeService = null) - : IService + IGitHubCommitRangeService? commitRangeService = null +) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); private readonly IChangelogFileSystem _fileSystem = fileSystem; @@ -263,8 +263,7 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle // The --files / path-list filter follows the same gate: in CDN mode the requested paths are // matched to pool entries by file name, so private repos whose entries exist only in S3 (with // PR/issue references scrubbed from the public copies) can still bundle by explicit selection. - var useLocalChangelogs = (config?.Bundle?.UseLocalChangelogs ?? false) - || input.ForceLocal; + var useLocalChangelogs = (config?.Bundle?.UseLocalChangelogs ?? false) || input.ForceLocal; var authoringRepo = ChangelogRepoOwnerResolver.NormalizeRepo(input.Repo); var authoringOwner = ChangelogRepoOwnerResolver.ResolveOwner(input.Owner, input.Repo, DefaultOwner); var authoringBranch = string.IsNullOrWhiteSpace(input.Branch) ? DefaultBranch : input.Branch; @@ -426,28 +425,24 @@ private async Task BuildAndWriteBundle( ChangelogConfiguration? config, IReadOnlyList entries, string outputPath, - Cancel ctx) + Cancel ctx + ) { // Apply rules.bundle secondary filter (three modes: none, global content, per-product context). // Input stage (--input-products, --prs, etc.) and bundle filtering stage are conceptually separate. var filteredEntries = entries; if (config?.Rules?.Bundle != null) { - var outputProductIds = input.OutputProducts - ?.Select(p => p.Product) - .Where(p => !string.IsNullOrWhiteSpace(p)) - .Select(p => p!) - .ToList(); + var outputProductIds = input.OutputProducts?.Select(p => p.Product).Where(p => !string.IsNullOrWhiteSpace(p)).Select( + p => p! + ).ToList(); var mode = config.Rules.Bundle.DetermineFilterMode(); filteredEntries = mode switch { BundleFilterMode.NoFiltering => filteredEntries, BundleFilterMode.GlobalContent => ApplyGlobalContentBundleFilter(collector, filteredEntries, config.Rules.Bundle), - BundleFilterMode.PerProductContext => ApplyPerProductContextBundleFilter( - collector, - filteredEntries, - config.Rules.Bundle, - outputProductIds), + BundleFilterMode.PerProductContext => + ApplyPerProductContextBundleFilter(collector, filteredEntries, config.Rules.Bundle, outputProductIds), _ => filteredEntries }; } @@ -481,14 +476,17 @@ private async Task BuildAndWriteBundle( var bundleData = buildResult.Data; if (input.LinkAllowRepos != null) { - if (!LinkAllowlistSanitizer.TryApplyBundle( - collector, - bundleData, - input.LinkAllowRepos, - input.Owner ?? "elastic", - input.Repo, - out var sanitizedBundle, - out _)) + if ( + !LinkAllowlistSanitizer.TryApplyBundle( + collector, + bundleData, + input.LinkAllowRepos, + input.Owner ?? "elastic", + input.Repo, + out var sanitizedBundle, + out _ + ) + ) return false; bundleData = sanitizedBundle; @@ -504,7 +502,8 @@ private async Task BuildAndWriteBundle( { collector.EmitWarning( string.Empty, - $"Could not load assembler.yml for bundle.link_allow_repos diagnostics: {ex.Message}"); + $"Could not load assembler.yml for bundle.link_allow_repos diagnostics: {ex.Message}" + ); } } } @@ -512,17 +511,23 @@ private async Task BuildAndWriteBundle( // Apply description with placeholder substitution if (!string.IsNullOrEmpty(input.Description)) { - var version = (input.OutputProducts?.Count > 0 ? input.OutputProducts[0].Target : null) - ?? (bundleData.Products.Count > 0 ? bundleData.Products[0].Target : null); - var lifecycle = (input.OutputProducts?.Count > 0 ? input.OutputProducts[0].Lifecycle : null) - ?? (bundleData.Products.Count > 0 ? bundleData.Products[0].Lifecycle?.ToStringFast(true) : null); + var version = (input.OutputProducts?.Count > 0 ? input.OutputProducts[0].Target : null) ?? + (bundleData.Products.Count > 0 ? bundleData.Products[0].Target : null); + var lifecycle = (input.OutputProducts?.Count > 0 ? input.OutputProducts[0].Lifecycle : null) ?? + (bundleData.Products.Count > 0 ? bundleData.Products[0].Lifecycle?.ToStringFast(true) : null); var owner = input.Owner ?? "elastic"; var repo = input.Repo ?? (bundleData.Products.Count > 0 ? bundleData.Products[0].ProductId : null) ?? "unknown"; try { var substitutedDescription = BundleDescriptionSubstitution.SubstitutePlaceholders( - input.Description, version, lifecycle, owner, repo, validateResolvable: true); + input.Description, + version, + lifecycle, + owner, + repo, + validateResolvable: true + ); bundleData = bundleData with { Description = substitutedDescription }; } catch (InvalidOperationException ex) @@ -565,7 +570,12 @@ private async Task BuildAndWriteBundle( return true; } - private async Task ProcessProfile(IDiagnosticsCollector collector, BundleChangelogsArguments input, ChangelogConfiguration? config, Cancel ctx) + private async Task ProcessProfile( + IDiagnosticsCollector collector, + BundleChangelogsArguments input, + ChangelogConfiguration? config, + Cancel ctx + ) { // Commit-range mode derives its PR list from git; the profile only contributes output // metadata (output/output_products/repo/owner/branch/description), not a filter source. @@ -602,9 +612,7 @@ private async Task BuildAndWriteBundle( // For all other profile types, infer it from the base version string. var resolvedLifecycle = filterResult.Lifecycle ?? VersionLifecycleInference.InferLifecycle(filterResult.Version); - var outputPattern = profile.Output? - .Replace("{version}", filterResult.Version) - .Replace("{lifecycle}", resolvedLifecycle); + var outputPattern = profile.Output?.Replace("{version}", filterResult.Version).Replace("{lifecycle}", resolvedLifecycle); if (!string.IsNullOrWhiteSpace(outputPattern)) { // Resolution order: bundle.output_directory → input.OutputDirectory (programmatic override) @@ -637,10 +645,18 @@ private async Task BuildAndWriteBundle( var outputProductsPattern = profile.OutputProducts .Replace("{version}", filterResult.Version) .Replace("{lifecycle}", resolvedLifecycle); - if (!ProfileFilterResolver.TryParseProfileProducts(outputProductsPattern, out var parsedOutputProducts, out var outputProductsParseError)) + if ( + !ProfileFilterResolver.TryParseProfileProducts( + outputProductsPattern, + out var parsedOutputProducts, + out var outputProductsParseError + ) + ) { - collector.EmitError(string.Empty, - $"Profile '{input.Profile}': bundle.output_products could not be parsed: {outputProductsParseError}"); + collector.EmitError( + string.Empty, + $"Profile '{input.Profile}': bundle.output_products could not be parsed: {outputProductsParseError}" + ); return null; } @@ -662,27 +678,34 @@ private async Task BuildAndWriteBundle( var hasVersionPlaceholder = descriptionTemplate.Contains("{version}") || descriptionTemplate.Contains("{lifecycle}"); var hasOwnerRepoPlaceholder = descriptionTemplate.Contains("{owner}") || descriptionTemplate.Contains("{repo}"); - if (hasVersionPlaceholder && - filterResult.Version == "unknown" && - string.IsNullOrEmpty(profile.OutputProducts)) + if (hasVersionPlaceholder && filterResult.Version == "unknown" && string.IsNullOrEmpty(profile.OutputProducts)) { - collector.EmitError(string.Empty, + collector.EmitError( + string.Empty, $"Profile '{input.Profile}' uses {{version}} or {{lifecycle}} placeholders in description but no version is available for substitution. " + - "Either provide a version argument, or add 'output_products' pattern to the profile configuration."); + "Either provide a version argument, or add 'output_products' pattern to the profile configuration." + ); return null; } - if (hasOwnerRepoPlaceholder && - (string.IsNullOrEmpty(owner) || string.IsNullOrEmpty(repo))) + if (hasOwnerRepoPlaceholder && (string.IsNullOrEmpty(owner) || string.IsNullOrEmpty(repo))) { - collector.EmitError(string.Empty, + collector.EmitError( + string.Empty, $"Profile '{input.Profile}' uses {{owner}} or {{repo}} placeholders in description but values are not resolvable. " + - "Ensure repository metadata is available in the configuration."); + "Ensure repository metadata is available in the configuration." + ); return null; } - profileDescription = BundleDescriptionSubstitution.SubstitutePlaceholders( - descriptionTemplate, filterResult.Version, resolvedLifecycle, owner, repo); + profileDescription = + BundleDescriptionSubstitution.SubstitutePlaceholders( + descriptionTemplate, + filterResult.Version, + resolvedLifecycle, + owner, + repo + ); } } @@ -717,7 +740,10 @@ private static bool ValidateGitRefArguments(IDiagnosticsCollector collector, Bun { if (input.DryRun) { - collector.EmitError(string.Empty, "--dry-run is only supported when bundling a git commit range (--start-git-ref/--end-git-ref)."); + collector.EmitError( + string.Empty, + "--dry-run is only supported when bundling a git commit range (--start-git-ref/--end-git-ref)." + ); return false; } @@ -726,8 +752,10 @@ private static bool ValidateGitRefArguments(IDiagnosticsCollector collector, Bun if (hasStart != hasEnd) { - collector.EmitError(string.Empty, - "--start-git-ref and --end-git-ref must be provided together; the start ref is never inferred from previous bundles."); + collector.EmitError( + string.Empty, + "--start-git-ref and --end-git-ref must be provided together; the start ref is never inferred from previous bundles." + ); return false; } @@ -749,9 +777,11 @@ private static bool ValidateGitRefArguments(IDiagnosticsCollector collector, Bun if (conflicting.Count > 0) { - collector.EmitError(string.Empty, + collector.EmitError( + string.Empty, $"--start-git-ref/--end-git-ref cannot be combined with other filter sources: {string.Join(", ", conflicting)}. " + - "The PR list is derived from the commit range itself."); + "The PR list is derived from the commit range itself." + ); return false; } @@ -766,7 +796,8 @@ private static bool ValidateGitRefArguments(IDiagnosticsCollector collector, Bun private static ProfileFilterResult? ResolveGitRangeProfileFilter( IDiagnosticsCollector collector, BundleChangelogsArguments input, - ChangelogConfiguration? config) + ChangelogConfiguration? config + ) { if (config?.Bundle?.Profiles == null || !config.Bundle.Profiles.TryGetValue(input.Profile!, out var profile)) { @@ -776,24 +807,30 @@ private static bool ValidateGitRefArguments(IDiagnosticsCollector collector, Bun if (string.IsNullOrWhiteSpace(input.ProfileArgument)) { - collector.EmitError(string.Empty, - $"Profile '{input.Profile}' requires a version as the second argument when bundling a git commit range"); + collector.EmitError( + string.Empty, + $"Profile '{input.Profile}' requires a version as the second argument when bundling a git commit range" + ); return null; } if (string.Equals(profile.Source, "github_release", StringComparison.OrdinalIgnoreCase)) { - collector.EmitError(string.Empty, + collector.EmitError( + string.Empty, $"Profile '{input.Profile}': 'source: github_release' cannot be combined with --start-git-ref/--end-git-ref. " + - "The PR list is derived from the commit range itself."); + "The PR list is derived from the commit range itself." + ); return null; } if (!string.IsNullOrWhiteSpace(profile.Products)) { - collector.EmitError(string.Empty, + collector.EmitError( + string.Empty, $"Profile '{input.Profile}' has a 'products' pattern configured. " + - "A git commit range cannot be combined with a products pattern filter; use 'output_products' and 'rules' to shape the bundle."); + "A git commit range cannot be combined with a products pattern filter; use 'output_products' and 'rules' to shape the bundle." + ); return null; } @@ -820,24 +857,26 @@ private async Task BundleFromGitRange( BundleChangelogsArguments input, ChangelogConfiguration? config, GitRangeSourcingContext sourcing, - Cancel ctx) + Cancel ctx + ) { if (string.IsNullOrWhiteSpace(sourcing.Repo)) { - collector.EmitError(string.Empty, + collector.EmitError( + string.Empty, "Bundling a git commit range requires a resolvable authoring repository. " + - "Set bundle.repo in changelog.yml (or pass --repo)."); + "Set bundle.repo in changelog.yml (or pass --repo)." + ); return false; } var owner = string.IsNullOrWhiteSpace(sourcing.Owner) ? DefaultOwner : sourcing.Owner; - var resolution = await _commitRangeService.ResolvePullRequestsAsync(collector, new CommitRangeArguments - { - Owner = owner, - Repo = sourcing.Repo, - StartRef = input.StartGitRef!, - EndRef = input.EndGitRef! - }, ctx); + var resolution = + await _commitRangeService.ResolvePullRequestsAsync( + collector, + new CommitRangeArguments { Owner = owner, Repo = sourcing.Repo, StartRef = input.StartGitRef!, EndRef = input.EndGitRef! }, + ctx + ); if (resolution == null) return false; @@ -851,14 +890,22 @@ private async Task BundleFromGitRange( return false; var resolver = new GitRangeEntryResolver(_prService, _logger); - var result = await resolver.ResolveAsync(collector, resolution, candidates, config, new GitRangeEntryResolutionOptions - { - Owner = owner, - Repo = sourcing.Repo, - StartRef = input.StartGitRef!, - EndRef = input.EndGitRef!, - FallbackProducts = input.OutputProducts - }, ctx); + var result = + await resolver.ResolveAsync( + collector, + resolution, + candidates, + config, + new GitRangeEntryResolutionOptions + { + Owner = owner, + Repo = sourcing.Repo, + StartRef = input.StartGitRef!, + EndRef = input.EndGitRef!, + FallbackProducts = input.OutputProducts + }, + ctx + ); var report = result.Report.ToMarkdown(); _logger.LogInformation("Commit-range bundle report:\n{Report}", report); @@ -875,8 +922,10 @@ private async Task BundleFromGitRange( if (result.Entries.Count == 0) { - collector.EmitError(string.Empty, - $"No changelog entries could be resolved for commit range {input.StartGitRef}..{input.EndGitRef} of {owner}/{sourcing.Repo}."); + collector.EmitError( + string.Empty, + $"No changelog entries could be resolved for commit range {input.StartGitRef}..{input.EndGitRef} of {owner}/{sourcing.Repo}." + ); return false; } @@ -888,7 +937,8 @@ private async Task BundleFromGitRange( IDiagnosticsCollector collector, string directory, string outputPath, - Cancel ctx) + Cancel ctx + ) { if (!_fileSystem.Directory.Exists(directory)) { @@ -957,7 +1007,8 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments IDiagnosticsCollector collector, BundleChangelogsArguments input, bool hasReleaseVersion, - Cancel ctx) + Cancel ctx + ) { var needsNetwork = hasReleaseVersion; var needsGithubToken = hasReleaseVersion; @@ -987,8 +1038,7 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments config = await _configLoader.LoadChangelogConfiguration(collector, input.Config, ctx); BundleProfile? profileDef = null; - if (!string.IsNullOrWhiteSpace(input.Profile) && - config?.Bundle?.Profiles?.TryGetValue(input.Profile, out profileDef) == true) + if (!string.IsNullOrWhiteSpace(input.Profile) && config?.Bundle?.Profiles?.TryGetValue(input.Profile, out profileDef) == true) { if (string.Equals(profileDef.Source, "github_release", StringComparison.OrdinalIgnoreCase)) { @@ -1000,8 +1050,7 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments // CDN entry sourcing needs network access for the Docker bundle run. Mirror the run-mode gate: // active when the authoring repo resolves (profile/config bundle.repo), the user has not forced // local sourcing, and a CDN base is configured. - var useLocalChangelogs = (config?.Bundle?.UseLocalChangelogs ?? false) - || input.ForceLocal; + var useLocalChangelogs = (config?.Bundle?.UseLocalChangelogs ?? false) || input.ForceLocal; var explicitDirectory = !string.IsNullOrWhiteSpace(input.Directory); var authoringRepo = ChangelogRepoOwnerResolver.NormalizeRepo(input.Repo ?? profileDef?.Repo ?? config?.Bundle?.Repo); if (ShouldSourceFromCdn(authoringRepo, useLocalChangelogs: useLocalChangelogs, explicitDirectory: explicitDirectory)) @@ -1013,25 +1062,21 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments { var version = input.ProfileArgument ?? "unknown"; var lifecycle = VersionLifecycleInference.InferLifecycle(version); - var outputPattern = profileDef.Output - .Replace("{version}", version) - .Replace("{lifecycle}", lifecycle); - var outputDir = config?.Bundle?.OutputDirectory - ?? config?.Bundle?.Directory - ?? _fileSystem.Directory.GetCurrentDirectory(); + var outputPattern = profileDef.Output.Replace("{version}", version).Replace("{lifecycle}", lifecycle); + var outputDir = config?.Bundle?.OutputDirectory ?? config?.Bundle?.Directory ?? _fileSystem.Directory.GetCurrentDirectory(); outputPath = _fileSystem.Path.Join(outputDir, outputPattern).OptionalWindowsReplace(); } - else if (string.IsNullOrWhiteSpace(outputPath) && - !string.IsNullOrWhiteSpace(input.StartGitRef) && - profileDef != null && - !string.IsNullOrWhiteSpace(input.ProfileArgument) && - ResolvePrimaryProduct(profileDef, input) is { } primaryProduct) + else if ( + string.IsNullOrWhiteSpace(outputPath) + && !string.IsNullOrWhiteSpace(input.StartGitRef) + && profileDef != null + && !string.IsNullOrWhiteSpace(input.ProfileArgument) + && ResolvePrimaryProduct(profileDef, input) is { } primaryProduct + ) { // Mirror ProcessProfile's commit-range convention: {product}-{version}.yaml when the // profile sets no explicit output pattern. - var outputDir = config?.Bundle?.OutputDirectory - ?? config?.Bundle?.Directory - ?? _fileSystem.Directory.GetCurrentDirectory(); + var outputDir = config?.Bundle?.OutputDirectory ?? config?.Bundle?.Directory ?? _fileSystem.Directory.GetCurrentDirectory(); outputPath = _fileSystem.Path.Join(outputDir, $"{primaryProduct}-{input.ProfileArgument}.yaml").OptionalWindowsReplace(); } else if (string.IsNullOrWhiteSpace(outputPath) && config?.Bundle?.OutputDirectory != null) @@ -1102,14 +1147,17 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments string? org, string? repo, string? branch, - Cancel ctx) + Cancel ctx + ) { if (string.IsNullOrWhiteSpace(repo)) { - collector.EmitError(string.Empty, + collector.EmitError( + string.Empty, "Sourcing changelog entries from the CDN requires a resolvable authoring repository. " + - "Set bundle.repo in changelog.yml (or pass --repo), or set bundle.use_local_changelogs: true " + - "in changelog.yml / pass --directory to bundle local changelog files."); + "Set bundle.repo in changelog.yml (or pass --repo), or set bundle.use_local_changelogs: true " + + "in changelog.yml / pass --directory to bundle local changelog files." + ); return null; } @@ -1121,20 +1169,28 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments var baseUri = ChangelogCdn.ResolveBaseUri(); if (baseUri is null) { - collector.EmitError(string.Empty, - $"No valid changelog CDN base URL is configured. Set the {ChangelogCdn.BaseUrlEnvironmentVariable} environment variable to an absolute http(s) URL."); + collector.EmitError( + string.Empty, + $"No valid changelog CDN base URL is configured. Set the {ChangelogCdn.BaseUrlEnvironmentVariable} environment variable to an absolute http(s) URL." + ); return null; } var fatalFailure = false; - var entries = await _entryFetcher.FetchAsync( - baseUri, - resolvedOrg, - repo, - resolvedBranch, - msg => { fatalFailure = true; collector.EmitError(string.Empty, msg); }, - msg => collector.EmitWarning(string.Empty, msg), - ctx); + var entries = + await _entryFetcher.FetchAsync( + baseUri, + resolvedOrg, + repo, + resolvedBranch, + msg => + { + fatalFailure = true; + collector.EmitError(string.Empty, msg); + }, + msg => collector.EmitWarning(string.Empty, msg), + ctx + ); // The fetcher emits an error (via the callback above) for any fatal condition — a registry that // cannot be read, or a registry-listed entry still missing after its retry budget. Either would @@ -1146,8 +1202,11 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments foreach (var entry in entries) byName[entry.FileName] = entry.Content; - _logger.LogInformation("Sourced {Count} changelog entr(ies) from the CDN for {Pool}", - byName.Count, $"{resolvedOrg}/{repo}/{resolvedBranch}"); + _logger.LogInformation( + "Sourced {Count} changelog entr(ies) from the CDN for {Pool}", + byName.Count, + $"{resolvedOrg}/{repo}/{resolvedBranch}" + ); return byName.Select(kv => (kv.Key, kv.Value)).ToList(); } @@ -1171,7 +1230,8 @@ private static bool ShouldSourceFromCdn(string? authoringRepo, bool useLocalChan IDiagnosticsCollector collector, IReadOnlyList<(string FileName, string Content)> contents, IReadOnlyList requestedEntryNames, - string poolLabel) + string poolLabel + ) { var byName = new Dictionary(StringComparer.Ordinal); foreach (var (fileName, content) in contents) @@ -1192,14 +1252,20 @@ private static bool ShouldSourceFromCdn(string? authoringRepo, bool useLocalChan if (missing.Count > 0) { - collector.EmitError(string.Empty, + collector.EmitError( + string.Empty, $"Changelog entr{(missing.Count == 1 ? "y" : "ies")} not found in the CDN pool '{poolLabel}': {string.Join(", ", missing)}. " + - "Ensure the entries were uploaded (changelog upload), or pass --force-local / --directory to bundle local files instead."); + "Ensure the entries were uploaded (changelog upload), or pass --force-local / --directory to bundle local files instead." + ); return null; } - _logger.LogInformation("Selected {Selected} of {Total} CDN entries by requested file name for {Pool}", - selected.Count, contents.Count, poolLabel); + _logger.LogInformation( + "Selected {Selected} of {Total} CDN entries by requested file name for {Pool}", + selected.Count, + contents.Count, + poolLabel + ); return selected; } @@ -1232,14 +1298,19 @@ private bool ValidateInput(IDiagnosticsCollector collector, BundleChangelogsArgu if (specifiedFilters.Count == 0) { - collector.EmitError(string.Empty, "At least one filter option must be specified: --all, --input-products, --prs, --issues, or --files"); + collector.EmitError( + string.Empty, + "At least one filter option must be specified: --all, --input-products, --prs, --issues, or --files" + ); return false; } if (specifiedFilters.Count > 1) { - collector.EmitError(string.Empty, - $"Multiple filter options cannot be specified together. You specified: {string.Join(", ", specifiedFilters)}. Please use only one filter option: --all, --input-products, --prs, --issues, or --files"); + collector.EmitError( + string.Empty, + $"Multiple filter options cannot be specified together. You specified: {string.Join(", ", specifiedFilters)}. Please use only one filter option: --all, --input-products, --prs, --issues, or --files" + ); return false; } @@ -1254,16 +1325,18 @@ private static bool ValidatePlaceholderUsage(IDiagnosticsCollector collector, Bu if (string.IsNullOrEmpty(input.Description)) return true; - var hasPlaceholders = input.Description.Contains("{version}") || - input.Description.Contains("{lifecycle}") || - input.Description.Contains("{owner}") || - input.Description.Contains("{repo}"); + var hasPlaceholders = input.Description.Contains("{version}") + || input.Description.Contains("{lifecycle}") + || input.Description.Contains("{owner}") + || input.Description.Contains("{repo}"); if (hasPlaceholders && (input.OutputProducts == null || input.OutputProducts.Count == 0)) { - collector.EmitError(string.Empty, + collector.EmitError( + string.Empty, "When using placeholders in bundle description in option-based mode, " + - "--output-products must be explicitly specified to ensure predictable substitution values."); + "--output-products must be explicitly specified to ensure predictable substitution values." + ); return false; } @@ -1273,7 +1346,8 @@ private static bool ValidatePlaceholderUsage(IDiagnosticsCollector collector, Bu private static ChangelogFilterCriteria BuildFilterCriteria( BundleChangelogsArguments input, HashSet prsToMatch, - HashSet issuesToMatch) + HashSet issuesToMatch + ) { var productFilters = new List(); if (input.InputProducts is { Count: > 0 }) @@ -1351,8 +1425,10 @@ internal static string NormalizePrForComparison(string pr, string? defaultOwner, pr = pr.Trim(); // Handle full URL: https://github.com/owner/repo/pull/123 - if (pr.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase) || - pr.StartsWith("http://github.com/", StringComparison.OrdinalIgnoreCase)) + if ( + pr.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase) || + pr.StartsWith("http://github.com/", StringComparison.OrdinalIgnoreCase) + ) { // Use regex to parse URL more reliably var match = GitHubPrUrlRegex().Match(pr); @@ -1361,8 +1437,7 @@ internal static string NormalizePrForComparison(string pr, string? defaultOwner, var owner = match.Groups[1].Value.Trim(); var repo = match.Groups[2].Value.Trim(); var prPart = match.Groups[3].Value.Trim(); - if (!string.IsNullOrWhiteSpace(owner) && !string.IsNullOrWhiteSpace(repo) && - int.TryParse(prPart, out var prNum)) + if (!string.IsNullOrWhiteSpace(owner) && !string.IsNullOrWhiteSpace(repo) && int.TryParse(prPart, out var prNum)) return $"{owner}/{repo}#{prNum}".ToLowerInvariant(); } @@ -1377,8 +1452,7 @@ internal static string NormalizePrForComparison(string pr, string? defaultOwner, var owner = segments[1].TrimEnd('/').Trim(); var repo = segments[2].TrimEnd('/').Trim(); var prPart = segments[4].TrimEnd('/').Trim(); - if (!string.IsNullOrWhiteSpace(owner) && !string.IsNullOrWhiteSpace(repo) && - int.TryParse(prPart, out var prNum)) + if (!string.IsNullOrWhiteSpace(owner) && !string.IsNullOrWhiteSpace(repo) && int.TryParse(prPart, out var prNum)) return $"{owner}/{repo}#{prNum}".ToLowerInvariant(); } } @@ -1408,8 +1482,7 @@ internal static string NormalizePrForComparison(string pr, string? defaultOwner, } // Handle just a PR number when owner/repo are provided - if (int.TryParse(pr, out var prNumber) && - !string.IsNullOrWhiteSpace(defaultOwner) && !string.IsNullOrWhiteSpace(defaultRepo)) + if (int.TryParse(pr, out var prNumber) && !string.IsNullOrWhiteSpace(defaultOwner) && !string.IsNullOrWhiteSpace(defaultRepo)) return $"{defaultOwner}/{defaultRepo}#{prNumber}".ToLowerInvariant(); // Return as-is for comparison (fallback) @@ -1420,8 +1493,10 @@ internal static string NormalizeIssueForComparison(string issue, string? default { issue = issue.Trim(); - if (issue.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase) || - issue.StartsWith("http://github.com/", StringComparison.OrdinalIgnoreCase)) + if ( + issue.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase) || + issue.StartsWith("http://github.com/", StringComparison.OrdinalIgnoreCase) + ) { var match = GitHubIssueUrlRegex().Match(issue); if (match is { Success: true, Groups.Count: >= 4 }) @@ -1429,8 +1504,7 @@ internal static string NormalizeIssueForComparison(string issue, string? default var owner = match.Groups[1].Value.Trim(); var repo = match.Groups[2].Value.Trim(); var issuePart = match.Groups[3].Value.Trim(); - if (!string.IsNullOrWhiteSpace(owner) && !string.IsNullOrWhiteSpace(repo) && - int.TryParse(issuePart, out var issueNum)) + if (!string.IsNullOrWhiteSpace(owner) && !string.IsNullOrWhiteSpace(repo) && int.TryParse(issuePart, out var issueNum)) return $"{owner}/{repo}#{issueNum}".ToLowerInvariant(); } @@ -1443,8 +1517,7 @@ internal static string NormalizeIssueForComparison(string issue, string? default var owner = segments[1].TrimEnd('/').Trim(); var repo = segments[2].TrimEnd('/').Trim(); var issuePart = segments[4].TrimEnd('/').Trim(); - if (!string.IsNullOrWhiteSpace(owner) && !string.IsNullOrWhiteSpace(repo) && - int.TryParse(issuePart, out var issueNum)) + if (!string.IsNullOrWhiteSpace(owner) && !string.IsNullOrWhiteSpace(repo) && int.TryParse(issuePart, out var issueNum)) return $"{owner}/{repo}#{issueNum}".ToLowerInvariant(); } } @@ -1472,8 +1545,7 @@ internal static string NormalizeIssueForComparison(string issue, string? default } } - if (int.TryParse(issue, out var issueNumber) && - !string.IsNullOrWhiteSpace(defaultOwner) && !string.IsNullOrWhiteSpace(defaultRepo)) + if (int.TryParse(issue, out var issueNumber) && !string.IsNullOrWhiteSpace(defaultOwner) && !string.IsNullOrWhiteSpace(defaultRepo)) return $"{defaultOwner}/{defaultRepo}#{issueNumber}".ToLowerInvariant(); return issue.ToLowerInvariant(); @@ -1482,7 +1554,8 @@ internal static string NormalizeIssueForComparison(string issue, string? default private static IReadOnlyList ApplyGlobalContentBundleFilter( IDiagnosticsCollector collector, IReadOnlyList entries, - BundleRules bundleRules) + BundleRules bundleRules + ) { var filtered = new List(); var warnedMissingProducts = false; @@ -1495,16 +1568,24 @@ private static IReadOnlyList ApplyGlobalContentBundleFilte { if (!warnedMissingProducts) { - collector.EmitWarning(entry.FilePath, - "[-bundle-global] Changelog has no products declared; product filters are skipped for this entry. See documentation for rules.bundle global mode."); + collector.EmitWarning( + entry.FilePath, + "[-bundle-global] Changelog has no products declared; product filters are skipped for this entry. See documentation for rules.bundle global mode." + ); warnedMissingProducts = true; } else - collector.EmitWarning(entry.FilePath, "[-bundle-global] Changelog has no products declared; product filters are skipped for this entry."); + collector.EmitWarning( + entry.FilePath, + "[-bundle-global] Changelog has no products declared; product filters are skipped for this entry." + ); if (bundleRules.Blocker != null && bundleRules.Blocker.ShouldBlock(entry.Data)) { - collector.EmitWarning(entry.FilePath, $"[-bundle-type-area] Excluding '{entry.FileName}' from bundle (global type/area filter)."); + collector.EmitWarning( + entry.FilePath, + $"[-bundle-type-area] Excluding '{entry.FileName}' from bundle (global type/area filter)." + ); continue; } @@ -1514,13 +1595,19 @@ private static IReadOnlyList ApplyGlobalContentBundleFilte if (ShouldExcludeByProductFilter(entryProducts, bundleRules, out var productReason)) { - collector.EmitWarning(entry.FilePath, $"[-bundle-{productReason}] Excluding '{entry.FileName}' from bundle (global product filter)."); + collector.EmitWarning( + entry.FilePath, + $"[-bundle-{productReason}] Excluding '{entry.FileName}' from bundle (global product filter)." + ); continue; } if (bundleRules.Blocker != null && bundleRules.Blocker.ShouldBlock(entry.Data)) { - collector.EmitWarning(entry.FilePath, $"[-bundle-type-area] Excluding '{entry.FileName}' from bundle (global type/area filter)."); + collector.EmitWarning( + entry.FilePath, + $"[-bundle-type-area] Excluding '{entry.FileName}' from bundle (global type/area filter)." + ); continue; } @@ -1534,22 +1621,23 @@ private static IReadOnlyList ApplyPerProductContextBundleF IDiagnosticsCollector collector, IReadOnlyList entries, BundleRules bundleRules, - IReadOnlyList? outputProductIds = null) + IReadOnlyList? outputProductIds = null + ) { // Early validation: validate bundle has some product context - if ((outputProductIds == null || outputProductIds.Count == 0) && - !entries.Any(e => e.Data.Products?.Any() == true)) + if ((outputProductIds == null || outputProductIds.Count == 0) && !entries.Any(e => e.Data.Products?.Any() == true)) { - collector.EmitError(string.Empty, - "Bundle has no product context - specify output_products or ensure changelogs declare products"); + collector.EmitError( + string.Empty, + "Bundle has no product context - specify output_products or ensure changelogs declare products" + ); return []; } // BUNDLE-LEVEL: Determine rule context product once for entire bundle // Always use alphabetical first for consistency, regardless of source - var ruleContextProduct = outputProductIds?.OrderBy(id => id, StringComparer.OrdinalIgnoreCase).FirstOrDefault() - ?? entries - .SelectMany(e => e.Data.Products?.Select(p => p.ProductId) ?? []) + var ruleContextProduct = outputProductIds?.OrderBy(id => id, StringComparer.OrdinalIgnoreCase).FirstOrDefault() ?? + entries.SelectMany(e => e.Data.Products?.Select(p => p.ProductId) ?? []) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(id => id, StringComparer.OrdinalIgnoreCase) .FirstOrDefault(); @@ -1567,47 +1655,62 @@ private static IReadOnlyList ApplyPerProductContextBundleF switch (resolveResult.Result) { case ResolveResult.ExcludeMissingProducts: - collector.EmitWarning(entry.FilePath, $"[-bundle-missing-products] Excluding '{entry.FileName}' from bundle (no products declared)."); + collector.EmitWarning( + entry.FilePath, + $"[-bundle-missing-products] Excluding '{entry.FileName}' from bundle (no products declared)." + ); ruleStats["excluded_no_products"] = ruleStats.GetValueOrDefault("excluded_no_products") + 1; continue; - case ResolveResult.ExcludeDisjoint: - collector.EmitHint(entry.FilePath, $"[-bundle-disjoint] Excluding '{entry.FileName}' from bundle (disjoint from rule context '{ruleContextProduct}')."); + collector.EmitHint( + entry.FilePath, + $"[-bundle-disjoint] Excluding '{entry.FileName}' from bundle (disjoint from rule context '{ruleContextProduct}')." + ); ruleStats["excluded_disjoint"] = ruleStats.GetValueOrDefault("excluded_disjoint") + 1; continue; - case ResolveResult.UsePerProduct when resolveResult.Rule != null: // Apply per-product rule ruleStats[ruleContextProduct ?? "unknown"] = ruleStats.GetValueOrDefault(ruleContextProduct ?? "unknown") + 1; // Emit hint about ineffective pattern usage (once per bundle, not per entry) - if (resolveResult.Rule.MatchProducts == MatchMode.Any && - resolveResult.Rule.IncludeProducts?.Count > 0 && - !ruleStats.ContainsKey("ineffective_pattern_warned")) + if ( + resolveResult.Rule.MatchProducts == MatchMode.Any + && resolveResult.Rule.IncludeProducts?.Count > 0 + && !ruleStats.ContainsKey("ineffective_pattern_warned") + ) { - var wouldIncludeAll = resolveResult.Rule.IncludeProducts.Contains(ruleContextProduct ?? "", StringComparer.OrdinalIgnoreCase); - collector.EmitHint(string.Empty, + var wouldIncludeAll = resolveResult.Rule + .IncludeProducts + .Contains(ruleContextProduct ?? "", StringComparer.OrdinalIgnoreCase); + collector.EmitHint( + string.Empty, $"Note: Per-product rule '{ruleContextProduct}' uses 'match_products: any' with 'include_products' which acts as " + - $"{(wouldIncludeAll ? "include-all" : "exclude-all")} for this context. " + - $"Refer to https://github.com/elastic/docs-builder/blob/main/docs/contribute/configure-changelogs-ref.md"); + $"{(wouldIncludeAll ? "include-all" : "exclude-all")} for this context. " + + $"Refer to https://github.com/elastic/docs-builder/blob/main/docs/contribute/configure-changelogs-ref.md" + ); ruleStats["ineffective_pattern_warned"] = 1; } // 1 — Product filter: use per-product rule if (ShouldExcludeByResolvedProductRule(entryProducts, resolveResult.Rule, out var productReason)) { - collector.EmitWarning(entry.FilePath, $"[-bundle-{productReason}] Excluding '{entry.FileName}' from bundle (per-product filter)."); + collector.EmitWarning( + entry.FilePath, + $"[-bundle-{productReason}] Excluding '{entry.FileName}' from bundle (per-product filter)." + ); continue; } // 2 — Type/area filter: use per-product blocker if (resolveResult.Rule.Blocker != null && resolveResult.Rule.Blocker.ShouldBlock(entry.Data)) { - collector.EmitWarning(entry.FilePath, $"[-bundle-type-area] Excluding '{entry.FileName}' from bundle (per-product type/area filter)."); + collector.EmitWarning( + entry.FilePath, + $"[-bundle-type-area] Excluding '{entry.FileName}' from bundle (per-product type/area filter)." + ); continue; } break; - case ResolveResult.PassThrough: ruleStats["pass_through"] = ruleStats.GetValueOrDefault("pass_through") + 1; break; @@ -1621,6 +1724,7 @@ private static IReadOnlyList ApplyPerProductContextBundleF { var message = $"Applied rules - {string.Join(", ", ruleStats.Select(kvp => $"{kvp.Key}: {kvp.Value} entries"))}"; if (ruleStats.Count > 2) // More than one rule type being used + { message += ". Review rules.bundle configuration and documentation if this distribution seems unexpected."; } @@ -1637,13 +1741,13 @@ private static IReadOnlyList ApplyPerProductContextBundleF private static bool EntryMatchesProductList( IReadOnlyList entryProducts, IReadOnlyList list, - MatchMode matchProducts) => - matchProducts switch - { - MatchMode.All => entryProducts.All(p => list.Contains(p, StringComparer.OrdinalIgnoreCase)), - MatchMode.Conjunction => list.All(id => entryProducts.Contains(id, StringComparer.OrdinalIgnoreCase)), - _ => entryProducts.Any(p => list.Contains(p, StringComparer.OrdinalIgnoreCase)) - }; + MatchMode matchProducts + ) => matchProducts switch + { + MatchMode.All => entryProducts.All(p => list.Contains(p, StringComparer.OrdinalIgnoreCase)), + MatchMode.Conjunction => list.All(id => entryProducts.Contains(id, StringComparer.OrdinalIgnoreCase)), + _ => entryProducts.Any(p => list.Contains(p, StringComparer.OrdinalIgnoreCase)) + }; private static bool ShouldExcludeByProductFilter(IReadOnlyList entryProducts, BundleRules bundleRules, out string reason) { @@ -1665,7 +1769,11 @@ private static bool ShouldExcludeByProductFilter(IReadOnlyList entryProd return false; } - private static bool ShouldExcludeByResolvedProductRule(IReadOnlyList entryProducts, BundlePerProductRule rule, out string reason) + private static bool ShouldExcludeByResolvedProductRule( + IReadOnlyList entryProducts, + BundlePerProductRule rule, + out string reason + ) { if (rule.ExcludeProducts is { Count: > 0 } excludeList) { @@ -1685,12 +1793,11 @@ private static bool ShouldExcludeByResolvedProductRule(IReadOnlyList ent return false; } - - private static ResolveResultWithRule ResolvePerProductBundleRule( IReadOnlyList entryProducts, BundleRules bundleRules, - string? ruleContextProduct) + string? ruleContextProduct + ) { if (bundleRules.ByProduct is not { Count: > 0 } byProduct) return ResolveResultWithRule.PassThrough(); diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogEntryMatcher.cs b/src/services/Elastic.Changelog/Bundling/ChangelogEntryMatcher.cs index 56f9f259ab..f5ff8d9d88 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogEntryMatcher.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogEntryMatcher.cs @@ -25,7 +25,8 @@ public async Task MatchChangelogsAsync( IDiagnosticsCollector collector, IReadOnlyList yamlFiles, ChangelogFilterCriteria criteria, - Cancel ctx) + Cancel ctx + ) { var changelogEntries = new List(); var matchedPrs = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -51,7 +52,8 @@ public ChangelogMatchResult MatchChangelogContents( IDiagnosticsCollector collector, IReadOnlyList<(string FileName, string Content)> contents, ChangelogFilterCriteria criteria, - Cancel ctx) + Cancel ctx + ) { var changelogEntries = new List(); var matchedPrs = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -74,7 +76,8 @@ private static ChangelogMatchResult BuildResult( List changelogEntries, ChangelogFilterCriteria criteria, HashSet matchedPrs, - HashSet matchedIssues) + HashSet matchedIssues + ) { if (criteria.PrsToMatch.Count > 0) { @@ -88,12 +91,7 @@ private static ChangelogMatchResult BuildResult( collector.EmitWarning(string.Empty, $"No changelog file found for issue: {issue}"); } - return new ChangelogMatchResult - { - Entries = changelogEntries, - MatchedPrs = matchedPrs, - MatchedIssues = matchedIssues - }; + return new ChangelogMatchResult { Entries = changelogEntries, MatchedPrs = matchedPrs, MatchedIssues = matchedIssues }; } private async Task ProcessFileAsync( @@ -103,7 +101,8 @@ private static ChangelogMatchResult BuildResult( HashSet seenChangelogs, HashSet matchedPrs, HashSet matchedIssues, - Cancel ctx) + Cancel ctx + ) { string fileContent; try @@ -129,7 +128,8 @@ private static ChangelogMatchResult BuildResult( ChangelogFilterCriteria criteria, HashSet seenChangelogs, HashSet matchedPrs, - HashSet matchedIssues) + HashSet matchedIssues + ) { try { @@ -156,13 +156,7 @@ private static ChangelogMatchResult BuildResult( // Convert to domain type var data = ReleaseNotesSerialization.ConvertEntry(yamlDto); - return new MatchedChangelogFile - { - Data = data, - FilePath = filePath, - FileName = fileName, - Checksum = checksum - }; + return new MatchedChangelogFile { Data = data, FilePath = filePath, FileName = fileName, Checksum = checksum }; } catch (YamlException ex) { @@ -182,7 +176,8 @@ private static bool MatchesFilter( ChangelogEntryDto data, ChangelogFilterCriteria criteria, HashSet matchedPrs, - HashSet matchedIssues) + HashSet matchedIssues + ) { if (criteria.IncludeAll) return true; @@ -199,9 +194,7 @@ private static bool MatchesFilter( return true; } - private static bool MatchesProductFilter( - ChangelogEntryDto data, - IReadOnlyList productFilters) + private static bool MatchesProductFilter(ChangelogEntryDto data, IReadOnlyList productFilters) { if (data.Products == null || data.Products.Count == 0) return false; @@ -223,10 +216,7 @@ private static bool MatchesProductFilter( return false; } - private static bool MatchesPrFilter( - ChangelogEntryDto data, - ChangelogFilterCriteria criteria, - HashSet matchedPrs) + private static bool MatchesPrFilter(ChangelogEntryDto data, ChangelogFilterCriteria criteria, HashSet matchedPrs) { var prs = data.Prs ?? (data.Pr != null ? [data.Pr] : null); if (prs is not { Count: > 0 }) @@ -237,7 +227,11 @@ private static bool MatchesPrFilter( var normalizedPr = ChangelogBundlingService.NormalizePrForComparison(dataPr, criteria.DefaultOwner, criteria.DefaultRepo); foreach (var pr in criteria.PrsToMatch) { - var normalizedPrToMatch = ChangelogBundlingService.NormalizePrForComparison(pr, criteria.DefaultOwner, criteria.DefaultRepo); + var normalizedPrToMatch = ChangelogBundlingService.NormalizePrForComparison( + pr, + criteria.DefaultOwner, + criteria.DefaultRepo + ); if (normalizedPr == normalizedPrToMatch) { _ = matchedPrs.Add(pr); @@ -258,10 +252,18 @@ private static bool MatchesIssueFilter(ChangelogEntryDto data, ChangelogFilterCr { if (string.IsNullOrWhiteSpace(dataIssue)) continue; - var normalizedIssue = ChangelogBundlingService.NormalizeIssueForComparison(dataIssue, criteria.DefaultOwner, criteria.DefaultRepo); + var normalizedIssue = ChangelogBundlingService.NormalizeIssueForComparison( + dataIssue, + criteria.DefaultOwner, + criteria.DefaultRepo + ); foreach (var issue in criteria.IssuesToMatch) { - var normalizedIssueToMatch = ChangelogBundlingService.NormalizeIssueForComparison(issue, criteria.DefaultOwner, criteria.DefaultRepo); + var normalizedIssueToMatch = ChangelogBundlingService.NormalizeIssueForComparison( + issue, + criteria.DefaultOwner, + criteria.DefaultRepo + ); if (normalizedIssue == normalizedIssueToMatch) { _ = matchedIssues.Add(issue); diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogFileDiscovery.cs b/src/services/Elastic.Changelog/Bundling/ChangelogFileDiscovery.cs index bd5467625f..8e56966b6c 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogFileDiscovery.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogFileDiscovery.cs @@ -20,7 +20,8 @@ public async Task> DiscoverChangelogFilesAsync(string dire var outputFileName = fileSystem.Path.GetFileName(outputPath); // Read all YAML files from directory - var allYamlFiles = fileSystem.Directory.GetFiles(directory, "*.yaml", SearchOption.TopDirectoryOnly) + var allYamlFiles = fileSystem.Directory + .GetFiles(directory, "*.yaml", SearchOption.TopDirectoryOnly) .Concat(fileSystem.Directory.GetFiles(directory, "*.yml", SearchOption.TopDirectoryOnly)) .ToList(); @@ -49,8 +50,7 @@ private async Task IsBundleFileAsync(string filePath, string fileName, Can { var fileContent = await fileSystem.File.ReadAllTextAsync(filePath, ctx); // Bundle files have "entries:" at root level, changelog files don't - if (fileContent.Contains("entries:", StringComparison.Ordinal) && - fileContent.Contains("products:", StringComparison.Ordinal)) + if (fileContent.Contains("entries:", StringComparison.Ordinal) && fileContent.Contains("products:", StringComparison.Ordinal)) { logger.LogDebug("Skipping bundle file: {FileName}", fileName); return true; diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs index a39a9c4a8b..9874274784 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs @@ -62,8 +62,8 @@ public class ChangelogRemoveService( ILoggerFactory logFactory, IChangelogFileSystem fileSystem, IConfigurationContext? configurationContext = null, - IGitHubReleaseService? releaseService = null) - : IService + IGitHubReleaseService? releaseService = null +) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); private readonly IChangelogFileSystem _fileSystem = fileSystem; @@ -100,7 +100,8 @@ public async Task RemoveChangelogs(IDiagnosticsCollector collector, Change // Handle profile-based removal (same ordering as ChangelogBundlingService) if (!string.IsNullOrWhiteSpace(input.Profile)) { - var filterResult = await ProfileFilterResolver.ResolveAsync( + var filterResult = + await ProfileFilterResolver.ResolveAsync( collector, input.Profile, input.ProfileArgument, @@ -199,9 +200,7 @@ public async Task RemoveChangelogs(IDiagnosticsCollector collector, Change return false; } - var filesToRemove = matchResult.Entries - .Select(e => e.FilePath) - .ToList(); + var filesToRemove = matchResult.Entries.Select(e => e.FilePath).ToList(); if (input.DryRun) { @@ -271,14 +270,19 @@ private bool ValidateInput(IDiagnosticsCollector collector, ChangelogRemoveArgum if (specified.Count == 0) { - collector.EmitError(string.Empty, "At least one filter option must be specified: --all, --products, --prs, --issues, or --files"); + collector.EmitError( + string.Empty, + "At least one filter option must be specified: --all, --products, --prs, --issues, or --files" + ); return false; } if (specified.Count > 1) { - collector.EmitError(string.Empty, - $"Multiple filter options cannot be specified together. You specified: {string.Join(", ", specified)}. Please use only one filter option: --all, --products, --prs, --issues, or --files"); + collector.EmitError( + string.Empty, + $"Multiple filter options cannot be specified together. You specified: {string.Join(", ", specified)}. Please use only one filter option: --all, --products, --prs, --issues, or --files" + ); return false; } @@ -288,7 +292,8 @@ private bool ValidateInput(IDiagnosticsCollector collector, ChangelogRemoveArgum private static ChangelogFilterCriteria BuildFilterCriteria( ChangelogRemoveArguments input, HashSet prsToMatch, - HashSet issuesToMatch) + HashSet issuesToMatch + ) { var productFilters = new List(); if (input.Products is { Count: > 0 }) @@ -314,5 +319,4 @@ private static ChangelogFilterCriteria BuildFilterCriteria( DefaultRepo = input.Repo }; } - } diff --git a/src/services/Elastic.Changelog/Bundling/FileFilterLoader.cs b/src/services/Elastic.Changelog/Bundling/FileFilterLoader.cs index ada35a4be7..f119f43228 100644 --- a/src/services/Elastic.Changelog/Bundling/FileFilterLoader.cs +++ b/src/services/Elastic.Changelog/Bundling/FileFilterLoader.cs @@ -19,11 +19,7 @@ public class FileFilterLoader(IFileSystem fileSystem) /// /// Optional directory used to resolve basename-only or relative paths that are not found from the current directory. /// - public async Task LoadFilesAsync( - IDiagnosticsCollector collector, - string[]? files, - string? baseDirectory, - Cancel ctx) + public async Task LoadFilesAsync(IDiagnosticsCollector collector, string[]? files, string? baseDirectory, Cancel ctx) { var resolved = new List(); @@ -52,7 +48,10 @@ public async Task LoadFilesAsync( if (!IsYamlExtension(path)) { - collector.EmitError(path, $"--files values must be changelog YAML paths (.yaml/.yml) or a newline-delimited path list file. Found: {value}"); + collector.EmitError( + path, + $"--files values must be changelog YAML paths (.yaml/.yml) or a newline-delimited path list file. Found: {value}" + ); return new FileFilterResult { IsValid = false, FilePaths = resolved }; } @@ -74,10 +73,7 @@ public async Task LoadFilesAsync( /// existence, because the entries may exist only in S3 — or a newline-delimited path-list file /// (which must exist locally to be read). /// - public async Task LoadFileNamesAsync( - IDiagnosticsCollector collector, - string[]? files, - Cancel ctx) + public async Task LoadFileNamesAsync(IDiagnosticsCollector collector, string[]? files, Cancel ctx) { var names = new List(); @@ -99,7 +95,10 @@ public async Task LoadFileNamesAsync( if (LooksLikeHttpUrl(value) || !IsYamlExtension(value)) { - collector.EmitError(value, $"--files values must be changelog YAML paths (.yaml/.yml) or a newline-delimited path list file. Found: {rawValue}"); + collector.EmitError( + value, + $"--files values must be changelog YAML paths (.yaml/.yml) or a newline-delimited path list file. Found: {rawValue}" + ); return new FileFilterResult { IsValid = false, FilePaths = names }; } @@ -124,7 +123,8 @@ public async Task ReadPathListFileAsync( string listFilePath, string? baseDirectory, List resolved, - Cancel ctx) + Cancel ctx + ) { var lines = await ReadListLinesAsync(collector, listFilePath, ctx); if (lines == null) @@ -152,11 +152,7 @@ public async Task ReadPathListFileAsync( /// Reads a newline-delimited path list and appends the entry file names to , /// without requiring the listed paths to exist locally (CDN-pool matching). /// - private async Task ReadPathListNamesAsync( - IDiagnosticsCollector collector, - string listFilePath, - List names, - Cancel ctx) + private async Task ReadPathListNamesAsync(IDiagnosticsCollector collector, string listFilePath, List names, Cancel ctx) { var lines = await ReadListLinesAsync(collector, listFilePath, ctx); if (lines == null) @@ -177,8 +173,7 @@ private async Task ReadPathListNamesAsync( private async Task ReadListLinesAsync(IDiagnosticsCollector collector, string listFilePath, Cancel ctx) { var content = await fileSystem.File.ReadAllTextAsync(listFilePath, ctx); - var lines = content - .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Where(l => !string.IsNullOrWhiteSpace(l)) .ToArray(); @@ -195,19 +190,13 @@ private bool ValidatePathListLine(IDiagnosticsCollector collector, string listFi { if (LooksLikeHttpUrl(line)) { - collector.EmitError( - listFilePath, - $"Path list file must contain changelog YAML paths (.yaml/.yml), not URLs. Found: {line}" - ); + collector.EmitError(listFilePath, $"Path list file must contain changelog YAML paths (.yaml/.yml), not URLs. Found: {line}"); return false; } if (!IsYamlExtension(line)) { - collector.EmitError( - listFilePath, - $"Path list file must contain changelog YAML paths (.yaml/.yml). Found: {line}" - ); + collector.EmitError(listFilePath, $"Path list file must contain changelog YAML paths (.yaml/.yml). Found: {line}"); return false; } @@ -261,21 +250,19 @@ private void EmitMissingFileError(IDiagnosticsCollector collector, string file, collector.EmitError( file, $"File does not exist. Current directory: {currentDir}. " + - $"Tip: Repeat {optionName} for each file, or use comma-separated values (e.g., {optionName} \"file1.yaml,file2.yaml\"). " + - "Paths support tilde (~) expansion and can be relative or absolute." + $"Tip: Repeat {optionName} for each file, or use comma-separated values (e.g., {optionName} \"file1.yaml,file2.yaml\"). " + + "Paths support tilde (~) expansion and can be relative or absolute." ); } internal static bool IsYamlExtension(string path) { var ext = Path.GetExtension(path); - return ext.Equals(".yaml", StringComparison.OrdinalIgnoreCase) - || ext.Equals(".yml", StringComparison.OrdinalIgnoreCase); + return ext.Equals(".yaml", StringComparison.OrdinalIgnoreCase) || ext.Equals(".yml", StringComparison.OrdinalIgnoreCase); } private static bool LooksLikeHttpUrl(string value) => - value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) - || value.StartsWith("https://", StringComparison.OrdinalIgnoreCase); + value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || value.StartsWith("https://", StringComparison.OrdinalIgnoreCase); } /// Result of loading file-path filter values. diff --git a/src/services/Elastic.Changelog/Bundling/FilterLoaderUtilities.cs b/src/services/Elastic.Changelog/Bundling/FilterLoaderUtilities.cs index 9c672c5319..4d24b2a8aa 100644 --- a/src/services/Elastic.Changelog/Bundling/FilterLoaderUtilities.cs +++ b/src/services/Elastic.Changelog/Bundling/FilterLoaderUtilities.cs @@ -31,14 +31,11 @@ internal static string ExpandTilde(string path) var relativePath = trimmedPath[2..].TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); // Ensure that an accidentally rooted path segment does not cause the home directory // to be ignored by Path.Join. - return Path.IsPathRooted(relativePath) - ? relativePath - : Path.Join(homeDirectory, relativePath); + return Path.IsPathRooted(relativePath) ? relativePath : Path.Join(homeDirectory, relativePath); } private static bool IsUrl(string value) => - value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || - value.StartsWith("https://", StringComparison.OrdinalIgnoreCase); + value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || value.StartsWith("https://", StringComparison.OrdinalIgnoreCase); private static bool IsShortFormat(string value) { @@ -54,18 +51,23 @@ private static bool IsShortFormat(string value) } private static bool LooksLikeFilePath(IFileSystem fileSystem, string value) => - value.Contains(fileSystem.Path.DirectorySeparatorChar) || - value.Contains(fileSystem.Path.AltDirectorySeparatorChar) || - fileSystem.Path.HasExtension(value); + value.Contains(fileSystem.Path.DirectorySeparatorChar) + || value.Contains(fileSystem.Path.AltDirectorySeparatorChar) + || fileSystem.Path.HasExtension(value); private static async Task ReadUrlsFromFileAsync( - IFileSystem fileSystem, IDiagnosticsCollector collector, string filePath, - HashSet valuesToMatch, string exampleUrlSegment, Cancel ctx) + IFileSystem fileSystem, + IDiagnosticsCollector collector, + string filePath, + HashSet valuesToMatch, + string exampleUrlSegment, + Cancel ctx + ) { var content = await fileSystem.File.ReadAllTextAsync(filePath, ctx); - var lines = content - .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Where(p => !string.IsNullOrWhiteSpace(p)); + var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Where( + p => !string.IsNullOrWhiteSpace(p) + ); foreach (var line in lines) { @@ -74,7 +76,7 @@ private static async Task ReadUrlsFromFileAsync( collector.EmitError( filePath, $"File must contain fully-qualified GitHub URLs (e.g. https://github.com/owner/repo/{exampleUrlSegment}). " + - $"Numbers and short forms are not allowed. Found: {line}" + $"Numbers and short forms are not allowed. Found: {line}" ); return false; } @@ -84,8 +86,13 @@ private static async Task ReadUrlsFromFileAsync( } private static async Task ProcessSingleValueAsync( - IFileSystem fileSystem, IDiagnosticsCollector collector, string singleValue, - HashSet valuesToMatch, string exampleUrlSegment, Cancel ctx) + IFileSystem fileSystem, + IDiagnosticsCollector collector, + string singleValue, + HashSet valuesToMatch, + string exampleUrlSegment, + Cancel ctx + ) { var isUrl = IsUrl(singleValue); @@ -115,8 +122,14 @@ private static async Task ProcessSingleValueAsync( } private static async Task ProcessMultipleValuesAsync( - IFileSystem fileSystem, IDiagnosticsCollector collector, HashSet valuesToMatch, - List nonExistentFiles, string[] values, string exampleUrlSegment, Cancel ctx) + IFileSystem fileSystem, + IDiagnosticsCollector collector, + HashSet valuesToMatch, + List nonExistentFiles, + string[] values, + string exampleUrlSegment, + Cancel ctx + ) { foreach (var value in values) { @@ -148,12 +161,14 @@ private static async Task ProcessMultipleValuesAsync( } private static bool ValidateNumericValues( - IDiagnosticsCollector collector, HashSet valuesToMatch, - string? owner, string? repo, string numericValidationMessage) + IDiagnosticsCollector collector, + HashSet valuesToMatch, + string? owner, + string? repo, + string numericValidationMessage + ) { - var hasNumericOnly = valuesToMatch - .Where(v => !IsUrl(v) && !IsShortFormat(v)) - .Any(v => int.TryParse(v, out _)); + var hasNumericOnly = valuesToMatch.Where(v => !IsUrl(v) && !IsShortFormat(v)).Any(v => int.TryParse(v, out _)); if (hasNumericOnly && (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(repo))) { @@ -165,8 +180,15 @@ private static bool ValidateNumericValues( } public static async Task<(bool IsValid, HashSet Matches)> LoadValuesAsync( - IFileSystem fileSystem, IDiagnosticsCollector collector, string[]? values, - string? owner, string? repo, string exampleUrlSegment, string numericValidationMessage, Cancel ctx) + IFileSystem fileSystem, + IDiagnosticsCollector collector, + string[]? values, + string? owner, + string? repo, + string exampleUrlSegment, + string numericValidationMessage, + Cancel ctx + ) { var valuesToMatch = new HashSet(StringComparer.OrdinalIgnoreCase); diff --git a/src/services/Elastic.Changelog/Bundling/GitRangeEntryResolver.cs b/src/services/Elastic.Changelog/Bundling/GitRangeEntryResolver.cs index f4f4ed86fb..36a2663b84 100644 --- a/src/services/Elastic.Changelog/Bundling/GitRangeEntryResolver.cs +++ b/src/services/Elastic.Changelog/Bundling/GitRangeEntryResolver.cs @@ -86,12 +86,13 @@ public record GitRangeBundleReport public string ToMarkdown() { var sb = new StringBuilder(); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Changelog bundle for `{StartRef}..{EndRef}`") - .AppendLine() - .AppendLine(CultureInfo.InvariantCulture, $"{TotalCommits} commit(s), {Rows.Count} pull request(s).") - .AppendLine() - .AppendLine("| PR | Source | Entry |") - .AppendLine("|---|---|---|"); + _ = + sb.AppendLine(CultureInfo.InvariantCulture, $"### Changelog bundle for `{StartRef}..{EndRef}`") + .AppendLine() + .AppendLine(CultureInfo.InvariantCulture, $"{TotalCommits} commit(s), {Rows.Count} pull request(s).") + .AppendLine() + .AppendLine("| PR | Source | Entry |") + .AppendLine("|---|---|---|"); foreach (var row in Rows) { @@ -109,9 +110,7 @@ public string ToMarkdown() if (CommitsWithoutPullRequest.Count > 0) { - _ = sb.AppendLine() - .AppendLine("Commits without an associated pull request:") - .AppendLine(); + _ = sb.AppendLine().AppendLine("Commits without an associated pull request:").AppendLine(); foreach (var sha in CommitsWithoutPullRequest) _ = sb.AppendLine(CultureInfo.InvariantCulture, $"- `{sha}`"); } @@ -148,7 +147,8 @@ public async Task ResolveAsync( IReadOnlyList<(string FileName, string Content)> candidates, ChangelogConfiguration? config, GitRangeEntryResolutionOptions options, - Cancel ctx) + Cancel ctx + ) { var parsedCandidates = candidates.Select(c => ParseCandidate(c.FileName, c.Content)).ToList(); @@ -170,8 +170,10 @@ public async Task ResolveAsync( continue; if (match.Entry == null) { - collector.EmitError(match.FileName, - $"Changelog entry '{match.FileName}' matches PR #{pr.Number} but could not be parsed: {match.ParseError}"); + collector.EmitError( + match.FileName, + $"Changelog entry '{match.FileName}' matches PR #{pr.Number} but could not be parsed: {match.ParseError}" + ); success = false; continue; } @@ -206,15 +208,16 @@ public async Task ResolveAsync( CommitsWithoutPullRequest = resolution.CommitsWithoutPullRequest }; - return new GitRangeEntryResolutionResult - { - Success = success, - Entries = entries, - Report = report - }; + return new GitRangeEntryResolutionResult { Success = success, Entries = entries, Report = report }; } - private sealed record ParsedCandidate(string FileName, IReadOnlyList FileNameNumbers, MatchedChangelogFile? Entry, string? ParseError, IReadOnlyList NormalizedPrs); + private sealed record ParsedCandidate( + string FileName, + IReadOnlyList FileNameNumbers, + MatchedChangelogFile? Entry, + string? ParseError, + IReadOnlyList NormalizedPrs + ); private static ParsedCandidate ParseCandidate(string fileName, string content) { @@ -271,8 +274,9 @@ private static bool MatchesPr(ParsedCandidate candidate, int prNumber, GitRangeE return true; var expected = $"{options.Owner}/{options.Repo}#{prNumber}".ToLowerInvariant(); - return candidate.NormalizedPrs.Any(pr => - ChangelogBundlingService.NormalizePrForComparison(pr, options.Owner, options.Repo) == expected); + return candidate.NormalizedPrs.Any( + pr => ChangelogBundlingService.NormalizePrForComparison(pr, options.Owner, options.Repo) == expected + ); } /// @@ -286,21 +290,22 @@ private static bool MatchesPr(ParsedCandidate candidate, int prNumber, GitRangeE CommitRangePullRequest pr, ChangelogConfiguration? config, GitRangeEntryResolutionOptions options, - Cancel ctx) + Cancel ctx + ) { var prInfo = await prService.FetchPrInfoAsync(pr.Url, options.Owner, options.Repo, ctx); if (prInfo == null || string.IsNullOrWhiteSpace(prInfo.Title)) { - collector.EmitWarning(string.Empty, + collector.EmitWarning( + string.Empty, $"No checked-in changelog entry was found for PR {pr.Url} and its metadata could not be fetched from GitHub. " + - "The bundle will not include an entry for this PR."); + "The bundle will not include an entry for this PR." + ); return (Row(pr, GitRangePrSourceKind.Missing), null, false); } var labels = prInfo.Labels.ToArray(); - var labelProducts = config?.LabelToProducts != null - ? PrInfoProcessor.MapLabelsToProducts(labels, config.LabelToProducts) - : []; + var labelProducts = config?.LabelToProducts != null ? PrInfoProcessor.MapLabelsToProducts(labels, config.LabelToProducts) : []; if (config != null) { @@ -313,44 +318,39 @@ private static bool MatchesPr(ParsedCandidate candidate, int prNumber, GitRangeE if (config?.Extract.StripTitlePrefix == true) title = ChangelogTextUtilities.StripSquareBracketPrefix(title); - var typeString = config?.LabelToType != null - ? PrInfoProcessor.MapLabelsToType(labels, config.LabelToType) - : null; + var typeString = config?.LabelToType != null ? PrInfoProcessor.MapLabelsToType(labels, config.LabelToType) : null; if (typeString == null) { - collector.EmitWarning(pr.Url, + collector.EmitWarning( + pr.Url, $"Could not derive a changelog type from the labels of PR #{pr.Number}; defaulting to 'other'. " + - "Configure pivot.types in changelog.yml to map labels to types."); + "Configure pivot.types in changelog.yml to map labels to types." + ); } - var type = ChangelogEntryTypeExtensions.TryParse(typeString ?? "other", out var parsedType, ignoreCase: true, allowMatchingMetadataAttribute: true) - ? parsedType - : ChangelogEntryType.Other; + var type = ChangelogEntryTypeExtensions.TryParse( + typeString ?? "other", + out var parsedType, + ignoreCase: true, + allowMatchingMetadataAttribute: true + ) ? parsedType : ChangelogEntryType.Other; var products = ResolveProducts(collector, pr, labelProducts, options); if (products == null) return (Row(pr, GitRangePrSourceKind.Missing), null, true); - var description = config?.Extract.ReleaseNotes != false - ? ReleaseNotesExtractor.FindReleaseNote(prInfo.Body) - : null; + var description = config?.Extract.ReleaseNotes != false ? ReleaseNotesExtractor.FindReleaseNote(prInfo.Body) : null; - var areas = config?.LabelToAreas != null - ? PrInfoProcessor.MapLabelsToAreas(labels, config.LabelToAreas) - : []; + var areas = config?.LabelToAreas != null ? PrInfoProcessor.MapLabelsToAreas(labels, config.LabelToAreas) : []; var featureId = config?.LabelToFeatures != null ? PrInfoProcessor.MapLabelsToFeatureId(labels, config.LabelToFeatures, collector) : null; var highlight = config?.HighlightLabels is { Count: > 0 } highlightLabels && - labels.Any(label => highlightLabels.Contains(label, StringComparer.OrdinalIgnoreCase)) - ? true - : (bool?)null; + labels.Any(label => highlightLabels.Contains(label, StringComparer.OrdinalIgnoreCase)) ? true : (bool?)null; - var issues = config?.Extract.Issues != false && prInfo.LinkedIssues.Count > 0 - ? prInfo.LinkedIssues.ToList() - : null; + var issues = config?.Extract.Issues != false && prInfo.LinkedIssues.Count > 0 ? prInfo.LinkedIssues.ToList() : null; var entryData = new ChangelogEntry { @@ -389,7 +389,8 @@ private static bool MatchesPr(ParsedCandidate candidate, int prNumber, GitRangeE IDiagnosticsCollector collector, CommitRangePullRequest pr, IReadOnlyList labelProducts, - GitRangeEntryResolutionOptions options) + GitRangeEntryResolutionOptions options + ) { var source = labelProducts.Count > 0 ? labelProducts @@ -397,20 +398,17 @@ private static bool MatchesPr(ParsedCandidate candidate, int prNumber, GitRangeE if (source.Count == 0) { - collector.EmitError(string.Empty, + collector.EmitError( + string.Empty, $"Cannot determine products for the entry synthesized from PR {pr.Url}: its labels map to no product and no output products are configured. " + - "Configure pivot.products label mappings or set output_products on the bundle profile."); + "Configure pivot.products label mappings or set output_products on the bundle profile." + ); return null; } return source.Select(p => p.ToProductReference()).ToList(); } - private static GitRangePrReportRow Row(CommitRangePullRequest pr, GitRangePrSourceKind source, string? fileName = null) => new() - { - Number = pr.Number, - Url = pr.Url, - Source = source, - EntryFileNames = fileName != null ? [fileName] : [] - }; + private static GitRangePrReportRow Row(CommitRangePullRequest pr, GitRangePrSourceKind source, string? fileName = null) => + new() { Number = pr.Number, Url = pr.Url, Source = source, EntryFileNames = fileName != null ? [fileName] : [] }; } diff --git a/src/services/Elastic.Changelog/Bundling/IssueFilterLoader.cs b/src/services/Elastic.Changelog/Bundling/IssueFilterLoader.cs index 02cb8aa44a..de5a433265 100644 --- a/src/services/Elastic.Changelog/Bundling/IssueFilterLoader.cs +++ b/src/services/Elastic.Changelog/Bundling/IssueFilterLoader.cs @@ -21,14 +21,20 @@ public async Task LoadIssuesAsync( string[]? issues, string? owner, string? repo, - Cancel ctx) + Cancel ctx + ) { - var (isValid, matches) = await FilterLoaderUtilities.LoadValuesAsync( - fileSystem, collector, issues, owner, repo, - exampleUrlSegment: "issues/123", - numericValidationMessage: "When --issues contains issue numbers (not URLs or owner/repo#number format), both --owner and --repo must be provided", - ctx - ); + var (isValid, matches) = + await FilterLoaderUtilities.LoadValuesAsync( + fileSystem, + collector, + issues, + owner, + repo, + exampleUrlSegment: "issues/123", + numericValidationMessage: "When --issues contains issue numbers (not URLs or owner/repo#number format), both --owner and --repo must be provided", + ctx + ); return new IssueFilterResult { IsValid = isValid, IssuesToMatch = matches }; } } diff --git a/src/services/Elastic.Changelog/Bundling/LinkAllowlistSanitizer.cs b/src/services/Elastic.Changelog/Bundling/LinkAllowlistSanitizer.cs index 05e933df37..8ffff8cc25 100644 --- a/src/services/Elastic.Changelog/Bundling/LinkAllowlistSanitizer.cs +++ b/src/services/Elastic.Changelog/Bundling/LinkAllowlistSanitizer.cs @@ -34,7 +34,8 @@ public static bool TryApplyBundle( string defaultOwner, string? defaultBundleRepo, out Bundle sanitized, - out bool changesApplied) + out bool changesApplied + ) { sanitized = bundle; changesApplied = false; @@ -46,25 +47,11 @@ public static bool TryApplyBundle( foreach (var entry in bundle.Entries) { - var prs = ApplyToReferenceList( - collector, - entry.Prs, - ownerDefault, - defaultBundleRepo, - allow, - "PR", - ref anyRewritten); + var prs = ApplyToReferenceList(collector, entry.Prs, ownerDefault, defaultBundleRepo, allow, "PR", ref anyRewritten); if (prs == null && entry.Prs is not null) return false; - var issues = ApplyToReferenceList( - collector, - entry.Issues, - ownerDefault, - defaultBundleRepo, - allow, - "issue", - ref anyRewritten); + var issues = ApplyToReferenceList(collector, entry.Issues, ownerDefault, defaultBundleRepo, allow, "issue", ref anyRewritten); if (issues == null && entry.Issues is not null) return false; @@ -83,7 +70,8 @@ public static bool TryApplyBundle( public static void EmitAssemblerDiagnostics( IDiagnosticsCollector collector, IReadOnlyList linkAllowRepos, - AssemblyConfiguration? assembly) + AssemblyConfiguration? assembly + ) { if (assembly == null || linkAllowRepos.Count == 0) return; @@ -100,7 +88,8 @@ public static void EmitAssemblerDiagnostics( { collector.EmitWarning( string.Empty, - $"bundle.link_allow_repos entry '{entry}' is not listed in assembler.yml references (informational)."); + $"bundle.link_allow_repos entry '{entry}' is not listed in assembler.yml references (informational)." + ); continue; } @@ -108,7 +97,8 @@ public static void EmitAssemblerDiagnostics( { collector.EmitWarning( string.Empty, - $"bundle.link_allow_repos entry '{entry}' is marked private in assembler.yml; verify that published links are intended."); + $"bundle.link_allow_repos entry '{entry}' is marked private in assembler.yml; verify that published links are intended." + ); } } } @@ -149,7 +139,8 @@ public static bool TryApplyChangelogEntry( string defaultOwner, string? defaultRepo, out BundledEntry sanitized, - out bool changesApplied) + out bool changesApplied + ) { sanitized = entry; changesApplied = false; @@ -158,25 +149,11 @@ public static bool TryApplyChangelogEntry( var ownerDefault = string.IsNullOrWhiteSpace(defaultOwner) ? "elastic" : defaultOwner; var anyRewritten = false; - var prs = FilterReferenceList( - collector, - entry.Prs, - ownerDefault, - defaultRepo, - allow, - "PR", - ref anyRewritten); + var prs = FilterReferenceList(collector, entry.Prs, ownerDefault, defaultRepo, allow, "PR", ref anyRewritten); if (prs == null && entry.Prs is not null) return false; - var issues = FilterReferenceList( - collector, - entry.Issues, - ownerDefault, - defaultRepo, - allow, - "issue", - ref anyRewritten); + var issues = FilterReferenceList(collector, entry.Issues, ownerDefault, defaultRepo, allow, "issue", ref anyRewritten); if (issues == null && entry.Issues is not null) return false; @@ -184,14 +161,7 @@ public static bool TryApplyChangelogEntry( var impact = ScrubText(entry.Impact, allow, ref anyRewritten); var action = ScrubText(entry.Action, allow, ref anyRewritten); - sanitized = entry with - { - Prs = prs, - Issues = issues, - Description = description, - Impact = impact, - Action = action - }; + sanitized = entry with { Prs = prs, Issues = issues, Description = description, Impact = impact, Action = action }; changesApplied = anyRewritten; return true; } @@ -207,29 +177,36 @@ public static bool TryApplyChangelogEntry( var anyReplaced = false; - var result = GitHubUrlRegex().Replace(input, match => - { - var owner = match.Groups["owner"].Value; - var repo = match.Groups["repo"].Value; - var fullName = $"{owner}/{repo}"; - if (allow.Contains(fullName)) - return match.Value; - - anyReplaced = true; - return string.Empty; - }); - - result = ShortFormRefRegex().Replace(result, match => - { - var owner = match.Groups["owner"].Value; - var repo = match.Groups["repo"].Value; - var fullName = $"{owner}/{repo}"; - if (allow.Contains(fullName)) - return match.Value; + var result = GitHubUrlRegex().Replace( + input, + match => + { + var owner = match.Groups["owner"].Value; + var repo = match.Groups["repo"].Value; + var fullName = $"{owner}/{repo}"; + if (allow.Contains(fullName)) + return match.Value; + + anyReplaced = true; + return string.Empty; + } + ); - anyReplaced = true; - return string.Empty; - }); + result = + ShortFormRefRegex().Replace( + result, + match => + { + var owner = match.Groups["owner"].Value; + var repo = match.Groups["repo"].Value; + var fullName = $"{owner}/{repo}"; + if (allow.Contains(fullName)) + return match.Value; + + anyReplaced = true; + return string.Empty; + } + ); if (anyReplaced) changed = true; @@ -249,7 +226,8 @@ public static bool ScrubBundleForPublic( string defaultOwner, string? defaultBundleRepo, out Bundle sanitized, - out bool changesApplied) + out bool changesApplied + ) { sanitized = bundle; changesApplied = false; @@ -259,8 +237,17 @@ public static bool ScrubBundleForPublic( foreach (var entry in bundle.Entries) { - if (!TryApplyChangelogEntry(collector, entry, allowRepos, defaultOwner, defaultBundleRepo, - out var scrubbed, out var entryChanged)) + if ( + !TryApplyChangelogEntry( + collector, + entry, + allowRepos, + defaultOwner, + defaultBundleRepo, + out var scrubbed, + out var entryChanged + ) + ) return false; if (entryChanged) @@ -310,7 +297,8 @@ public static void ValidateNoPrivateReferences(string serializedYaml, IReadOnlyL if (violations.Count > 0) throw new InvalidOperationException( - $"Post-serialize validation failed: {violations.Count} private reference(s) found in public output: {string.Join(", ", violations)}"); + $"Post-serialize validation failed: {violations.Count} private reference(s) found in public output: {string.Join(", ", violations)}" + ); } /// @@ -329,7 +317,8 @@ public static void ValidateNoPrivateReferences(string serializedYaml, IReadOnlyL string? defaultBundleRepo, HashSet allow, string referenceKind, - ref bool anyDropped) + ref bool anyDropped + ) { if (refs is null) return null; @@ -352,7 +341,15 @@ public static void ValidateNoPrivateReferences(string serializedYaml, IReadOnlyL if (string.IsNullOrWhiteSpace(underlyingRef)) continue; - if (!ChangelogTextUtilities.TryGetGitHubRepo(underlyingRef, defaultOwner, defaultBundleRepo ?? string.Empty, out var sOwner, out var sRepo)) + if ( + !ChangelogTextUtilities.TryGetGitHubRepo( + underlyingRef, + defaultOwner, + defaultBundleRepo ?? string.Empty, + out var sOwner, + out var sRepo + ) + ) continue; if (allow.Contains($"{sOwner}/{sRepo}")) @@ -379,14 +376,16 @@ public static void ValidateNoPrivateReferences(string serializedYaml, IReadOnlyL list.Add(r); collector.EmitWarning( string.Empty, - $"Bare {referenceKind} reference '{r}' has no embedded owner/repo and no default repo was supplied; keeping as-is for downstream rendering to resolve."); + $"Bare {referenceKind} reference '{r}' has no embedded owner/repo and no default repo was supplied; keeping as-is for downstream rendering to resolve." + ); continue; } collector.EmitError( string.Empty, $"Link allowlist filtering could not parse {referenceKind} reference '{r}'. " + - "Use a full https://github.com/ URL, owner/repo#number, or a bare number with bundle owner/repo set."); + "Use a full https://github.com/ URL, owner/repo#number, or a bare number with bundle owner/repo set." + ); return null; } @@ -399,7 +398,8 @@ public static void ValidateNoPrivateReferences(string serializedYaml, IReadOnlyL anyDropped = true; collector.EmitWarning( string.Empty, - $"PR/issue reference '{r}' targets repository '{owner}/{repo}', which is not in the allowlist. It was removed from public output."); + $"PR/issue reference '{r}' targets repository '{owner}/{repo}', which is not in the allowlist. It was removed from public output." + ); } } @@ -428,7 +428,8 @@ private static HashSet BuildAllowSet(IReadOnlyList allowRepos) string? defaultBundleRepo, HashSet allow, string referenceKind, - ref bool anyRewritten) + ref bool anyRewritten + ) { if (refs is null) return null; @@ -472,7 +473,8 @@ private static HashSet BuildAllowSet(IReadOnlyList allowRepos) string? defaultBundleRepo, HashSet allow, string referenceKind, - ref bool anyRewritten) + ref bool anyRewritten + ) { var underlyingRef = sentinelRef.Substring(SentinelPrefix.Length).Trim(); @@ -481,16 +483,26 @@ private static HashSet BuildAllowSet(IReadOnlyList allowRepos) collector.EmitError( string.Empty, $"Invalid {referenceKind} sentinel '{sentinelRef}': no underlying reference found. " + - "Sentinels must have the format '# PRIVATE: '."); + "Sentinels must have the format '# PRIVATE: '." + ); return null; } - if (!ChangelogTextUtilities.TryGetGitHubRepo(underlyingRef, defaultOwner, defaultBundleRepo ?? string.Empty, out var owner, out var repo)) + if ( + !ChangelogTextUtilities.TryGetGitHubRepo( + underlyingRef, + defaultOwner, + defaultBundleRepo ?? string.Empty, + out var owner, + out var repo + ) + ) { collector.EmitError( string.Empty, $"Invalid {referenceKind} sentinel '{sentinelRef}': underlying reference '{underlyingRef}' could not be parsed. " + - "Use a full https://github.com/ URL, owner/repo#number, or a bare number with bundle owner/repo set."); + "Use a full https://github.com/ URL, owner/repo#number, or a bare number with bundle owner/repo set." + ); return null; } @@ -513,14 +525,16 @@ private static HashSet BuildAllowSet(IReadOnlyList allowRepos) string? defaultBundleRepo, HashSet allow, string referenceKind, - ref bool anyRewritten) + ref bool anyRewritten + ) { if (!ChangelogTextUtilities.TryGetGitHubRepo(r, defaultOwner, defaultBundleRepo ?? string.Empty, out var owner, out var repo)) { collector.EmitError( string.Empty, $"Link allowlist filtering could not parse {referenceKind} reference '{r}'. " + - "Use a full https://github.com/ URL, owner/repo#number, or a bare number with bundle owner/repo set."); + "Use a full https://github.com/ URL, owner/repo#number, or a bare number with bundle owner/repo set." + ); return null; } @@ -532,7 +546,8 @@ private static HashSet BuildAllowSet(IReadOnlyList allowRepos) collector.EmitWarning( string.Empty, $"PR/issue reference '{r}' targets repository '{fullName}', which is not in bundle.link_allow_repos. " + - "It was rewritten to a '# PRIVATE:' sentinel."); + "It was rewritten to a '# PRIVATE:' sentinel." + ); return $"{SentinelPrefix} {r}"; } @@ -553,19 +568,17 @@ private static bool TrySplitOwnerRepo(string entry, out string owner, out string return !string.IsNullOrWhiteSpace(owner) && !string.IsNullOrWhiteSpace(repo); } - private static bool TryFindReferenceRepository( - string owner, - string repo, - AssemblyConfiguration assembly, - out Repository? repository) + private static bool TryFindReferenceRepository(string owner, string repo, AssemblyConfiguration assembly, out Repository? repository) { var fullName = $"{owner}/{repo}"; var isElasticOwner = string.Equals(owner, "elastic", StringComparison.OrdinalIgnoreCase); foreach (var kvp in assembly.ReferenceRepositories) { - if (string.Equals(kvp.Key, fullName, StringComparison.OrdinalIgnoreCase) || - (isElasticOwner && string.Equals(kvp.Key, repo, StringComparison.OrdinalIgnoreCase))) + if ( + string.Equals(kvp.Key, fullName, StringComparison.OrdinalIgnoreCase) || + (isElasticOwner && string.Equals(kvp.Key, repo, StringComparison.OrdinalIgnoreCase)) + ) { repository = kvp.Value; return true; diff --git a/src/services/Elastic.Changelog/Bundling/PrFilterLoader.cs b/src/services/Elastic.Changelog/Bundling/PrFilterLoader.cs index ac1939657d..88d22aca0d 100644 --- a/src/services/Elastic.Changelog/Bundling/PrFilterLoader.cs +++ b/src/services/Elastic.Changelog/Bundling/PrFilterLoader.cs @@ -16,19 +16,19 @@ public class PrFilterLoader(IFileSystem fileSystem) /// Loads PR filter values from the provided input. /// Values can be file paths, URLs, short PR format (owner/repo#number), or PR numbers. /// - public async Task LoadPrsAsync( - IDiagnosticsCollector collector, - string[]? prs, - string? owner, - string? repo, - Cancel ctx) + public async Task LoadPrsAsync(IDiagnosticsCollector collector, string[]? prs, string? owner, string? repo, Cancel ctx) { - var (isValid, matches) = await FilterLoaderUtilities.LoadValuesAsync( - fileSystem, collector, prs, owner, repo, - exampleUrlSegment: "pull/123", - numericValidationMessage: "When --prs contains PR numbers (not URLs or owner/repo#number format), both --owner and --repo must be provided", - ctx - ); + var (isValid, matches) = + await FilterLoaderUtilities.LoadValuesAsync( + fileSystem, + collector, + prs, + owner, + repo, + exampleUrlSegment: "pull/123", + numericValidationMessage: "When --prs contains PR numbers (not URLs or owner/repo#number format), both --owner and --repo must be provided", + ctx + ); return new PrFilterResult { IsValid = isValid, PrsToMatch = matches }; } } diff --git a/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs b/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs index 004fb1b175..9736610bbf 100644 --- a/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs +++ b/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs @@ -87,7 +87,8 @@ public static partial class ProfileFilterResolver ILogger? logger, Cancel ctx, string? profileReport = null, - IGitHubReleaseService? releaseService = null) + IGitHubReleaseService? releaseService = null + ) { if (config?.Bundle?.Profiles == null || !config.Bundle.Profiles.TryGetValue(profileName, out var profile)) { @@ -97,17 +98,39 @@ public static partial class ProfileFilterResolver if (string.IsNullOrWhiteSpace(profileArgument)) { - collector.EmitError(string.Empty, $"Profile '{profileName}' requires a version number or promotion report URL as the second argument"); + collector.EmitError( + string.Empty, + $"Profile '{profileName}' requires a version number or promotion report URL as the second argument" + ); return null; } // Handle github_release source before the generic argument-type detection if (string.Equals(profile.Source, "github_release", StringComparison.OrdinalIgnoreCase)) - return await ResolveFromGitHubReleaseAsync(collector, profileName, profileArgument, profileReport, profile, config, releaseService, logger, ctx); + return await ResolveFromGitHubReleaseAsync( + collector, + profileName, + profileArgument, + profileReport, + profile, + config, + releaseService, + logger, + ctx + ); // When a separate report argument is provided, profileArgument is always the version if (profileReport != null) - return await ResolveWithSeparateReportAsync(collector, profileName, profileArgument, profileReport, profile, fileSystem, logger, ctx); + return await ResolveWithSeparateReportAsync( + collector, + profileName, + profileArgument, + profileReport, + profile, + fileSystem, + logger, + ctx + ); // Auto-detect argument type var argType = PromotionReportParser.DetectArgumentType(profileArgument); @@ -158,9 +181,7 @@ public static partial class ProfileFilterResolver // Substitute {version} and {lifecycle} in the products pattern var lifecycle = VersionLifecycleInference.InferLifecycle(version); - var productsPattern = profile.Products? - .Replace("{version}", version) - .Replace("{lifecycle}", lifecycle); + var productsPattern = profile.Products?.Replace("{version}", version).Replace("{lifecycle}", lifecycle); // If we have PRs, issues, or file paths from a file/report, return those directly if (prsFromReport != null) @@ -203,18 +224,20 @@ public static partial class ProfileFilterResolver BundleProfile profile, IChangelogFileSystem fileSystem, ILogger? logger, - Cancel ctx) + Cancel ctx + ) { // profileArgument must be a version string, not a file/URL var argType = PromotionReportParser.DetectArgumentType(profileArgument); - if (argType == ProfileArgumentType.PromotionReportUrl || - (argType == ProfileArgumentType.Version && fileSystem.File.Exists(profileArgument))) + if ( + argType == ProfileArgumentType.PromotionReportUrl || + (argType == ProfileArgumentType.Version && fileSystem.File.Exists(profileArgument)) + ) { collector.EmitError( string.Empty, "When two arguments are provided, the first must be a version string and the second must be a promotion report or URL list file. " + - $"'{profileArgument}' looks like a report path or URL. " + - $"Did you mean: {profileName} {profileArgument}?" + $"'{profileArgument}' looks like a report path or URL. " + $"Did you mean: {profileName} {profileArgument}?" ); return null; } @@ -225,7 +248,7 @@ public static partial class ProfileFilterResolver collector.EmitError( string.Empty, $"Profile '{profileName}' has a 'products' pattern configured. " + - "A promotion report, URL list file, or path list file cannot be combined with a products pattern filter." + "A promotion report, URL list file, or path list file cannot be combined with a products pattern filter." ); return null; } @@ -268,8 +291,8 @@ public static partial class ProfileFilterResolver collector.EmitError( string.Empty, $"The third argument '{profileReport}' must be a promotion report URL, a local HTML file, a URL list file, or a path list file. " + - "Use a URL (https://...), a local .html file, a text file containing fully-qualified GitHub PR/issue URLs, " + - "or a text file containing changelog YAML paths (.yaml/.yml)." + "Use a URL (https://...), a local .html file, a text file containing fully-qualified GitHub PR/issue URLs, " + + "or a text file containing changelog YAML paths (.yaml/.yml)." ); return null; } @@ -283,11 +306,11 @@ public static partial class ProfileFilterResolver IDiagnosticsCollector collector, string filePath, IChangelogFileSystem fileSystem, - Cancel ctx) + Cancel ctx + ) { var content = await fileSystem.File.ReadAllTextAsync(filePath, ctx); - var lines = content - .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Where(l => !string.IsNullOrWhiteSpace(l)) .ToArray(); @@ -317,8 +340,8 @@ public static partial class ProfileFilterResolver collector.EmitError( filePath, $"File must contain GitHub pull request or issue URLs " + - $"(e.g. https://github.com/owner/repo/pull/123 or https://github.com/owner/repo/issues/456), " + - $"or changelog YAML paths (.yaml/.yml). Not a recognized URL: {line}" + $"(e.g. https://github.com/owner/repo/pull/123 or https://github.com/owner/repo/issues/456), " + + $"or changelog YAML paths (.yaml/.yml). Not a recognized URL: {line}" ); return null; } @@ -332,7 +355,7 @@ public static partial class ProfileFilterResolver collector.EmitError( filePath, $"File must contain fully-qualified GitHub URLs (e.g. https://github.com/owner/repo/pull/123) " + - $"or changelog YAML paths (.yaml/.yml). Numbers and short forms are not allowed. Found: {line}" + $"or changelog YAML paths (.yaml/.yml). Numbers and short forms are not allowed. Found: {line}" ); return null; } @@ -351,9 +374,7 @@ public static partial class ProfileFilterResolver if (hasPaths) return new ListFileResult(null, null, lines); - return hasPrs - ? new ListFileResult(lines, null, null) - : new ListFileResult(null, lines, null); + return hasPrs ? new ListFileResult(lines, null, null) : new ListFileResult(null, lines, null); } private static ProfileArgumentType DetectLocalFileType(IChangelogFileSystem fileSystem, string path) => @@ -370,7 +391,8 @@ private static ProfileArgumentType DetectLocalFileType(IChangelogFileSystem file internal static bool TryParseProfileProducts( string pattern, [NotNullWhen(true)] out List? products, - [NotNullWhen(false)] out string? errorMessage) + [NotNullWhen(false)] out string? errorMessage + ) { products = null; errorMessage = null; @@ -395,7 +417,7 @@ internal static bool TryParseProfileProducts( { errorMessage = "Each product entry must have at most three space-separated fields (product, target, lifecycle). " + - $"Too many values in segment: '{entry}'."; + $"Too many values in segment: '{entry}'."; return false; } @@ -425,14 +447,15 @@ internal static bool TryParseProfileProducts( ChangelogConfiguration? config, IGitHubReleaseService? releaseService, ILogger? logger, - Cancel ctx) + Cancel ctx + ) { if (!string.IsNullOrWhiteSpace(profile.Products)) { collector.EmitError( string.Empty, $"Profile '{profileName}': 'source: github_release' cannot be combined with a 'products' filter. " + - "Remove the 'products' field or change the source." + "Remove the 'products' field or change the source." ); return null; } @@ -442,16 +465,19 @@ internal static bool TryParseProfileProducts( collector.EmitError( string.Empty, $"Profile '{profileName}': 'source: github_release' does not accept a third positional argument. " + - "The PR list is sourced automatically from the GitHub release. " + - "To override the lifecycle in 'output_products', hardcode the value instead of using {{lifecycle}} " + - "(for example, output_products: \"apm-agent-dotnet {{version}} preview\")." + "The PR list is sourced automatically from the GitHub release. " + + "To override the lifecycle in 'output_products', hardcode the value instead of using {{lifecycle}} " + + "(for example, output_products: \"apm-agent-dotnet {{version}} preview\")." ); return null; } if (releaseService == null) { - collector.EmitError(string.Empty, $"Profile '{profileName}': a GitHub release service is required for 'source: github_release'."); + collector.EmitError( + string.Empty, + $"Profile '{profileName}': a GitHub release service is required for 'source: github_release'." + ); return null; } @@ -464,7 +490,7 @@ internal static bool TryParseProfileProducts( collector.EmitError( string.Empty, $"Profile '{profileName}': 'source: github_release' requires a GitHub repository name. " + - "Set 'repo' on the profile or on the top-level 'bundle' configuration." + "Set 'repo' on the profile or on the top-level 'bundle' configuration." ); return null; } @@ -477,7 +503,7 @@ internal static bool TryParseProfileProducts( collector.EmitError( string.Empty, $"Profile '{profileName}': failed to fetch release '{profileArgument}' from {owner}/{repo}. " + - "Ensure the repository exists and the version tag is valid." + "Ensure the repository exists and the version tag is valid." ); return null; } @@ -485,23 +511,34 @@ internal static bool TryParseProfileProducts( logger?.LogInformation("Fetched release {Tag} from {Owner}/{Repo}", release.TagName, owner, repo); var parsed = ReleaseNoteParser.Parse(release.Body); - logger?.LogInformation("Detected release note format: {Format}, found {Count} PR references", parsed.Format, parsed.PrReferences.Count); + logger?.LogInformation( + "Detected release note format: {Format}, found {Count} PR references", + parsed.Format, + parsed.PrReferences.Count + ); if (parsed.PrReferences.Count == 0) { - collector.EmitWarning(string.Empty, $"Profile '{profileName}': no PR references found in release '{release.TagName}'. The bundle will be empty."); + collector.EmitWarning( + string.Empty, + $"Profile '{profileName}': no PR references found in release '{release.TagName}'. The bundle will be empty." + ); return null; } - var prUrls = parsed.PrReferences - .Select(pr => $"https://github.com/{owner}/{repo}/pull/{pr.PrNumber}") - .ToArray(); + var prUrls = parsed.PrReferences.Select(pr => $"https://github.com/{owner}/{repo}/pull/{pr.PrNumber}").ToArray(); var version = ChangelogTextUtilities.ExtractBaseVersion(release.TagName); // Infer lifecycle from the raw tag before base-version extraction so that pre-release suffixes // (e.g. "-preview.1", "-beta.1") are preserved for {lifecycle} substitution in output_products/output. var lifecycle = VersionLifecycleInference.InferLifecycle(release.TagName); - logger?.LogInformation("Resolved {Count} PR URLs from release {Tag} (version: {Version}, lifecycle: {Lifecycle})", prUrls.Length, release.TagName, version, lifecycle); + logger?.LogInformation( + "Resolved {Count} PR URLs from release {Tag} (version: {Version}, lifecycle: {Lifecycle})", + prUrls.Length, + release.TagName, + version, + lifecycle + ); return new ProfileFilterResult { Prs = prUrls, Version = version, Lifecycle = lifecycle }; } diff --git a/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs b/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs index 9a2c03e1c0..92debf56d0 100644 --- a/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs +++ b/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs @@ -26,12 +26,7 @@ public partial class PromotionReportParser(ILoggerFactory logFactory, IChangelog private static HttpClient CreateHttpClient() { - var handler = new SocketsHttpHandler - { - AllowAutoRedirect = false, - ConnectTimeout = TimeSpan.FromSeconds(10), - UseProxy = false - }; + var handler = new SocketsHttpHandler { AllowAutoRedirect = false, ConnectTimeout = TimeSpan.FromSeconds(10), UseProxy = false }; var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) }; client.DefaultRequestHeaders.Add("User-Agent", "docs-builder"); client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/html")); @@ -39,11 +34,13 @@ private static HttpClient CreateHttpClient() } private static bool IsAllowedUrl(string url) => - Uri.TryCreate(url, UriKind.Absolute, out var uri) && - uri.Scheme == Uri.UriSchemeHttps && - AllowedHosts.Any(domain => - uri.Host.Equals(domain, StringComparison.OrdinalIgnoreCase) || - uri.Host.EndsWith($".{domain}", StringComparison.OrdinalIgnoreCase)); + Uri.TryCreate(url, UriKind.Absolute, out var uri) + && uri.Scheme == Uri.UriSchemeHttps + && AllowedHosts.Any( + domain => + uri.Host.Equals(domain, StringComparison.OrdinalIgnoreCase) || + uri.Host.EndsWith($".{domain}", StringComparison.OrdinalIgnoreCase) + ); [GeneratedRegex(@"github\.com/([^/]+)/([^/]+)/pull/(\d+)", RegexOptions.IgnoreCase)] private static partial Regex GitHubPrUrlRegex(); @@ -57,8 +54,10 @@ public static ProfileArgumentType DetectArgumentType(string argument) return ProfileArgumentType.Unknown; // Check if it's a URL - if (argument.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || - argument.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + if ( + argument.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + argument.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + ) return ProfileArgumentType.PromotionReportUrl; // Check if it's a file path that exists (could be a promotion report file) @@ -71,8 +70,7 @@ public static ProfileArgumentType DetectArgumentType(string argument) /// /// Parses a promotion report and returns the extracted PR URLs, or null on failure (emitting errors). /// - public async Task ParseReportToPrUrlsAsync( - IDiagnosticsCollector collector, string source, Cancel ctx) + public async Task ParseReportToPrUrlsAsync(IDiagnosticsCollector collector, string source, Cancel ctx) { var result = await ParsePromotionReportAsync(source, ctx); if (result.IsValid) @@ -91,8 +89,10 @@ private async Task ParsePromotionReportAsync(string sourc { string htmlContent; - if (source.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || - source.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + if ( + source.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + source.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + ) { var (content, error) = await FetchReportUrlAsync(source, ctx); if (error != null) @@ -106,22 +106,14 @@ private async Task ParsePromotionReportAsync(string sourc } else { - return new PromotionReportResult - { - IsValid = false, - ErrorMessage = $"Promotion report source not found: {source}" - }; + return new PromotionReportResult { IsValid = false, ErrorMessage = $"Promotion report source not found: {source}" }; } var prUrls = ExtractPrUrlsFromHtml(htmlContent); if (prUrls.Count == 0) { - return new PromotionReportResult - { - IsValid = false, - ErrorMessage = "No PR URLs found in promotion report" - }; + return new PromotionReportResult { IsValid = false, ErrorMessage = "No PR URLs found in promotion report" }; } _logger.LogInformation("Extracted {Count} PR URLs from promotion report", prUrls.Count); @@ -131,20 +123,12 @@ private async Task ParsePromotionReportAsync(string sourc catch (HttpRequestException ex) { _logger.LogWarning(ex, "HTTP error fetching promotion report"); - return new PromotionReportResult - { - IsValid = false, - ErrorMessage = $"HTTP error fetching promotion report: {ex.Message}" - }; + return new PromotionReportResult { IsValid = false, ErrorMessage = $"HTTP error fetching promotion report: {ex.Message}" }; } catch (IOException ex) { _logger.LogWarning(ex, "IO error reading promotion report"); - return new PromotionReportResult - { - IsValid = false, - ErrorMessage = $"IO error reading promotion report: {ex.Message}" - }; + return new PromotionReportResult { IsValid = false, ErrorMessage = $"IO error reading promotion report: {ex.Message}" }; } } diff --git a/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs b/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs index 7a435eb8d6..25d1b503a9 100644 --- a/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs +++ b/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs @@ -69,11 +69,11 @@ public record CreateChangelogArguments /// Service for creating changelog entries /// public class ChangelogCreationService( -ILoggerFactory logFactory, -IConfigurationContext configurationContext, -IChangelogFileSystem fileSystem, -IGitHubPrService? githubPrService = null, -IEnvironmentVariables? env = null + ILoggerFactory logFactory, + IConfigurationContext configurationContext, + IChangelogFileSystem fileSystem, + IGitHubPrService? githubPrService = null, + IEnvironmentVariables? env = null ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); @@ -82,8 +82,7 @@ public class ChangelogCreationService( private readonly PrInfoProcessor _prProcessor = new(githubPrService, logFactory.CreateLogger()); private readonly IssueInfoProcessor _issueProcessor = new(githubPrService, logFactory.CreateLogger()); private readonly ChangelogFileWriter _fileWriter = new(fileSystem, logFactory.CreateLogger()); - private readonly ProductInferService _productInferService = new( - configurationContext.ProductsConfiguration); + private readonly ProductInferService _productInferService = new(configurationContext.ProductsConfiguration); public async Task CreateChangelog(IDiagnosticsCollector collector, CreateChangelogArguments input, Cancel ctx) { @@ -105,9 +104,7 @@ public async Task CreateChangelog(IDiagnosticsCollector collector, CreateC // When extraction is disabled (by CLI or config), discard any CI-injected description // that originated from evaluate-pr's release-note extraction. - if (input.ExtractionDisabled - && string.IsNullOrWhiteSpace(cliDescription) - && !string.IsNullOrWhiteSpace(input.Description)) + if (input.ExtractionDisabled && string.IsNullOrWhiteSpace(cliDescription) && !string.IsNullOrWhiteSpace(input.Description)) { _logger.LogInformation("Clearing CI-provided description because release note extraction is disabled"); input = input with { Description = null }; @@ -176,13 +173,13 @@ internal static CreateChangelogArguments ApplyConfigDefaults(CreateChangelogArgu // First, try config defaults if (productsConfig?.Default is { Count: > 0 }) { - var products = productsConfig.Default.Select(d => new ProductArgument - { - Product = d.Product, - Lifecycle = d.Lifecycle - }).ToList(); - _logger.LogInformation("Using default products from config: {Products}", - string.Join(", ", products.Select(p => $"{p.Product} ({p.Lifecycle})"))); + var products = productsConfig.Default + .Select(d => new ProductArgument { Product = d.Product, Lifecycle = d.Lifecycle }) + .ToList(); + _logger.LogInformation( + "Using default products from config: {Products}", + string.Join(", ", products.Select(p => $"{p.Product} ({p.Lifecycle})")) + ); return products; } @@ -202,7 +199,8 @@ private async Task CreateChangelogsForMultiplePrsAsync( IDiagnosticsCollector collector, CreateChangelogArguments input, ChangelogConfiguration config, - Cancel ctx) + Cancel ctx + ) { if (input.Prs == null || input.Prs.Length == 0) return false; @@ -218,8 +216,8 @@ private async Task CreateChangelogsForMultiplePrsAsync( foreach (var prTrimmed in input.Prs.Select(pr => pr.Trim()).Where(prTrimmed => !string.IsNullOrWhiteSpace(prTrimmed))) { // Check PR for blockers - var (shouldSkip, prInfo) = await _prProcessor.CheckPrForBlockersAsync( - collector, prTrimmed, input.Owner, input.Repo, input.Products, config, ctx); + var (shouldSkip, prInfo) = + await _prProcessor.CheckPrForBlockersAsync(collector, prTrimmed, input.Owner, input.Repo, input.Products, config, ctx); if (shouldSkip) { @@ -247,7 +245,12 @@ private async Task CreateChangelogsForMultiplePrsAsync( if (successCount == 0 && skippedCount == 0) return false; - _logger.LogInformation("Processed {SuccessCount} PR(s) successfully, skipped {SkippedCount} PR(s), {FetchFailedCount} PR(s) could not be fetched", successCount, skippedCount, fetchFailedCount); + _logger.LogInformation( + "Processed {SuccessCount} PR(s) successfully, skipped {SkippedCount} PR(s), {FetchFailedCount} PR(s) could not be fetched", + successCount, + skippedCount, + fetchFailedCount + ); return successCount > 0; } @@ -256,7 +259,8 @@ private async Task CreateSingleChangelogAsync( CreateChangelogArguments input, ChangelogConfiguration config, Cancel ctx, - bool reportFetchFailure = true) + bool reportFetchFailure = true + ) { // Get the PR URL if Prs is provided (for single PR processing) var prUrl = input.Prs is { Length: > 0 } ? input.Prs[0] : null; @@ -267,9 +271,7 @@ private async Task CreateSingleChangelogAsync( return false; // Fetch PR info when any derivable field is still missing (title, type, or products) - var needsDerivation = string.IsNullOrWhiteSpace(input.Title) - || string.IsNullOrWhiteSpace(input.Type) - || input.Products.Count == 0; + var needsDerivation = string.IsNullOrWhiteSpace(input.Title) || string.IsNullOrWhiteSpace(input.Type) || input.Products.Count == 0; if (!string.IsNullOrWhiteSpace(prUrl) && needsDerivation) { var prResult = await _prProcessor.ProcessPrAsync(collector, input, config, prUrl, ctx); @@ -313,14 +315,16 @@ private async Task CreateSingleChangelogAsync( config, string.IsNullOrWhiteSpace(input.Title), string.IsNullOrWhiteSpace(input.Type), - ctx); + ctx + ); } private async Task CreateChangelogsForMultipleIssuesAsync( IDiagnosticsCollector collector, CreateChangelogArguments input, ChangelogConfiguration config, - Cancel ctx) + Cancel ctx + ) { if (input.Issues == null || input.Issues.Length == 0) return false; @@ -334,8 +338,8 @@ private async Task CreateChangelogsForMultipleIssuesAsync( foreach (var issueUrl in input.Issues.Select(i => i.Trim()).Where(i => !string.IsNullOrWhiteSpace(i))) { - var (shouldSkip, issueInfo) = await _issueProcessor.CheckIssueForBlockersAsync( - collector, issueUrl, input.Owner, input.Repo, input.Products, config, ctx); + var (shouldSkip, issueInfo) = + await _issueProcessor.CheckIssueForBlockersAsync(collector, issueUrl, input.Owner, input.Repo, input.Products, config, ctx); if (shouldSkip) { @@ -359,7 +363,12 @@ private async Task CreateChangelogsForMultipleIssuesAsync( if (successCount == 0 && skippedCount == 0) return false; - _logger.LogInformation("Processed {SuccessCount} issue(s) successfully, skipped {SkippedCount} issue(s), {FetchFailedCount} issue(s) could not be fetched", successCount, skippedCount, fetchFailedCount); + _logger.LogInformation( + "Processed {SuccessCount} issue(s) successfully, skipped {SkippedCount} issue(s), {FetchFailedCount} issue(s) could not be fetched", + successCount, + skippedCount, + fetchFailedCount + ); return successCount > 0; } @@ -368,7 +377,8 @@ private async Task CreateSingleChangelogFromIssueAsync( CreateChangelogArguments input, ChangelogConfiguration config, Cancel ctx, - bool reportFetchFailure = true) + bool reportFetchFailure = true + ) { var issueUrl = input.Issues is { Length: > 0 } ? input.Issues[0] : null; @@ -408,20 +418,26 @@ private async Task CreateSingleChangelogFromIssueAsync( config, string.IsNullOrWhiteSpace(input.Title), string.IsNullOrWhiteSpace(input.Type), - ctx); + ctx + ); } /// Emits a single aggregate diagnostic (an error under strict mode) when items could not be fetched from GitHub during bulk creation. - private static void ReportBulkFetchFailures(IDiagnosticsCollector collector, int fetchFailedCount, int total, bool strict, string itemKind) + private static void ReportBulkFetchFailures( + IDiagnosticsCollector collector, + int fetchFailedCount, + int total, + bool strict, + string itemKind + ) { if (fetchFailedCount <= 0) return; - var message = - $"{fetchFailedCount} of {total} {itemKind}(s) could not be fetched from GitHub. " + - $"Their changelogs were created without rules.create label filtering and may be missing title or type, " + - $"which will cause 'changelog bundle' to fail. Verify GITHUB_TOKEN is set and can access the referenced " + - $"repositories, then delete the generated changelog files and re-run."; + var message = $"{fetchFailedCount} of {total} {itemKind}(s) could not be fetched from GitHub. " + + $"Their changelogs were created without rules.create label filtering and may be missing title or type, " + + $"which will cause 'changelog bundle' to fail. Verify GITHUB_TOKEN is set and can access the referenced " + + $"repositories, then delete the generated changelog files and re-run."; if (strict) collector.EmitError(string.Empty, message); @@ -430,9 +446,11 @@ private static void ReportBulkFetchFailures(IDiagnosticsCollector collector, int } private static void EmitStrictFetchError(IDiagnosticsCollector collector, string itemKind, string? url) => - collector.EmitError(string.Empty, + collector.EmitError( + string.Empty, $"Could not fetch {itemKind} '{url}' from GitHub and --strict-fetch is set. " + - "Verify GITHUB_TOKEN is set and can access the repository, then re-run."); + "Verify GITHUB_TOKEN is set and can access the repository, then re-run." + ); private static CreateChangelogArguments CreateInputForSinglePr(CreateChangelogArguments input, string prUrl) => input with { Prs = [prUrl] }; @@ -442,7 +460,9 @@ input with { Title = derived.Title != null && string.IsNullOrWhiteSpace(input.Title) ? derived.Title : input.Title, Type = derived.Type != null && string.IsNullOrWhiteSpace(input.Type) ? derived.Type : input.Type, - Description = derived.Description != null && string.IsNullOrWhiteSpace(input.Description) ? derived.Description : input.Description, + Description = derived.Description != null && string.IsNullOrWhiteSpace(input.Description) + ? derived.Description + : input.Description, Areas = derived.Areas != null && (input.Areas == null || input.Areas.Length == 0) ? derived.Areas : input.Areas, Products = derived.Products is { Count: > 0 } && input.Products.Count == 0 ? derived.Products : input.Products, Highlight = derived.Highlight ?? input.Highlight, @@ -474,13 +494,9 @@ internal CreateChangelogArguments EnrichFromCI(CreateChangelogArguments input) _logger.LogInformation("CI environment detected, enriching arguments from CHANGELOG_* env vars"); - var enrichedPrs = input.Prs is { Length: > 0 } - ? input.Prs - : !string.IsNullOrEmpty(prNumber) ? [prNumber] : input.Prs; + var enrichedPrs = input.Prs is { Length: > 0 } ? input.Prs : !string.IsNullOrEmpty(prNumber) ? [prNumber] : input.Prs; - var enrichedProducts = input.Products.Count > 0 - ? input.Products - : ProductArgument.ParseProductSpecs(ciProducts); + var enrichedProducts = input.Products.Count > 0 ? input.Products : ProductArgument.ParseProductSpecs(ciProducts); var enrichedDescription = !string.IsNullOrWhiteSpace(input.Description) ? input.Description diff --git a/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs b/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs index ce3ba610d2..5e7668266b 100644 --- a/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs +++ b/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs @@ -32,7 +32,8 @@ public async Task WriteChangelogAsync( ChangelogConfiguration config, bool titleMissing, bool typeMissing, - Cancel ctx) + Cancel ctx + ) { // Build changelog data from input var changelogData = BuildChangelogData(input); @@ -78,6 +79,7 @@ private string GenerateFilename(IDiagnosticsCollector collector, CreateChangelog { var joined = $"{string.Join("-", numbers)}.yaml"; if (joined.Length <= MaxFilenameLength + 5) // ".yaml" = 5 chars + return joined; // Too many PRs: use compact format to avoid path-too-long errors return $"{numbers[0]}-to-{numbers[^1]}-{numbers.Count}-prs.yaml"; @@ -114,23 +116,27 @@ private string GenerateFilename(IDiagnosticsCollector collector, CreateChangelog var slug = string.IsNullOrWhiteSpace(input.Title) ? firstPr != null ? $"pr-{firstPr.Replace("/", "-").Replace(":", "-")}" - : firstIssue != null - ? $"issue-{firstIssue.Replace("/", "-").Replace(":", "-")}" - : "changelog" + : firstIssue != null ? $"issue-{firstIssue.Replace("/", "-").Replace(":", "-")}" : "changelog" : ChangelogTextUtilities.SanitizeFilename(input.Title); return $"{timestamp}-{slug}.yaml"; } private static ChangelogEntry BuildChangelogData(CreateChangelogArguments input) { - var entryType = ChangelogEntryTypeExtensions.TryParse(input.Type, out var parsed, ignoreCase: true, allowMatchingMetadataAttribute: true) - ? parsed - : ChangelogEntryType.Other; + var entryType = ChangelogEntryTypeExtensions.TryParse( + input.Type, + out var parsed, + ignoreCase: true, + allowMatchingMetadataAttribute: true + ) ? parsed : ChangelogEntryType.Other; var subtype = !string.IsNullOrWhiteSpace(input.Subtype) - ? (ChangelogEntrySubtypeExtensions.TryParse(input.Subtype, out var subtypeParsed, ignoreCase: true, allowMatchingMetadataAttribute: true) - ? subtypeParsed - : (ChangelogEntrySubtype?)null) + ? (ChangelogEntrySubtypeExtensions.TryParse( + input.Subtype, + out var subtypeParsed, + ignoreCase: true, + allowMatchingMetadataAttribute: true + ) ? subtypeParsed : (ChangelogEntrySubtype?)null) : null; return new() @@ -215,10 +221,11 @@ private static string GenerateYaml(ChangelogEntry data, ChangelogConfiguration c } // Find the first non-empty, non-comment line (start of actual YAML data) - var insertIndex = lines.FindIndex(line => - !string.IsNullOrWhiteSpace(line) && - !line.TrimStart().StartsWith('#') && - !line.TrimStart().StartsWith("---", StringComparison.Ordinal)); + var insertIndex = lines.FindIndex( + line => + !string.IsNullOrWhiteSpace(line) && !line.TrimStart().StartsWith('#') && + !line.TrimStart().StartsWith("---", StringComparison.Ordinal) + ); lines.InsertRange(insertIndex >= 0 ? insertIndex : lines.Count, commentedFields); @@ -235,7 +242,8 @@ private static string GenerateYaml(ChangelogEntry data, ChangelogConfiguration c var lifecyclesList = string.Join("\n", config.Lifecycles.Select(l => $"# - {l.ToStringFast(true)}")); // Add schema comments using raw string literal - var result = $""" + var result = + $""" ##### Required fields ##### # title: diff --git a/src/services/Elastic.Changelog/Creation/CreateChangelogArgumentsValidator.cs b/src/services/Elastic.Changelog/Creation/CreateChangelogArgumentsValidator.cs index 3a3eda3d60..82d4fc825d 100644 --- a/src/services/Elastic.Changelog/Creation/CreateChangelogArgumentsValidator.cs +++ b/src/services/Elastic.Changelog/Creation/CreateChangelogArgumentsValidator.cs @@ -19,9 +19,11 @@ public class CreateChangelogArgumentsValidator(IConfigurationContext configurati /// public bool ValidatePrFormat(IDiagnosticsCollector collector, string? prUrl, string? owner, string? repo) { - if (!string.IsNullOrWhiteSpace(prUrl) + if ( + !string.IsNullOrWhiteSpace(prUrl) && int.TryParse(prUrl, out _) - && (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(repo))) + && (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(repo)) + ) { collector.EmitError(string.Empty, "When --prs is specified as just a number, both --owner and --repo must be provided"); return false; @@ -50,9 +52,11 @@ public bool ValidateMultiplePrFormat(IDiagnosticsCollector collector, string[] p /// public bool ValidateIssueFormat(IDiagnosticsCollector collector, string? issueUrl, string? owner, string? repo) { - if (!string.IsNullOrWhiteSpace(issueUrl) + if ( + !string.IsNullOrWhiteSpace(issueUrl) && int.TryParse(issueUrl.Trim(), out _) - && (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(repo))) + && (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(repo)) + ) { collector.EmitError(string.Empty, "When --issues is specified as just a number, both --owner and --repo must be provided"); return false; @@ -83,13 +87,17 @@ public bool ValidateRequiredFields( IDiagnosticsCollector collector, CreateChangelogArguments input, bool prFetchFailed, - bool fromIssue = false) + bool fromIssue = false + ) { // Validate title if (string.IsNullOrWhiteSpace(input.Title)) { if (prFetchFailed) - collector.EmitWarning(string.Empty, "Title is missing. The changelog will be created with title commented out. Please manually update the title field."); + collector.EmitWarning( + string.Empty, + "Title is missing. The changelog will be created with title commented out. Please manually update the title field." + ); else { var titleHint = fromIssue ? "specify --issues to derive it from the issue" : "specify --prs or --issues to derive it"; @@ -102,11 +110,17 @@ public bool ValidateRequiredFields( if (string.IsNullOrWhiteSpace(input.Type)) { if (prFetchFailed) - collector.EmitWarning(string.Empty, "Type is missing. The changelog will be created with type commented out. Please manually update the type field."); + collector.EmitWarning( + string.Empty, + "Type is missing. The changelog will be created with type commented out. Please manually update the type field." + ); else { var source = fromIssue ? "issue" : "PR"; - collector.EmitError(string.Empty, $"Type is required. Provide --type or specify --prs/--issues to derive it from {source} labels (requires pivot.types mapping in changelog.yml)."); + collector.EmitError( + string.Empty, + $"Type is required. Provide --type or specify --prs/--issues to derive it from {source} labels (requires pivot.types mapping in changelog.yml)." + ); return false; } } @@ -124,22 +138,25 @@ public bool ValidateRequiredFields( /// /// Validates input values against configuration. /// - public bool ValidateAgainstConfiguration( - IDiagnosticsCollector collector, - CreateChangelogArguments input, - ChangelogConfiguration config) + public bool ValidateAgainstConfiguration(IDiagnosticsCollector collector, CreateChangelogArguments input, ChangelogConfiguration config) { // Validate type is in allowed list (only if type is provided) if (!string.IsNullOrWhiteSpace(input.Type) && !config.Types.Contains(input.Type)) { - collector.EmitError(string.Empty, $"Type '{input.Type}' is not in the list of available types. Available types: {string.Join(", ", config.Types)}"); + collector.EmitError( + string.Empty, + $"Type '{input.Type}' is not in the list of available types. Available types: {string.Join(", ", config.Types)}" + ); return false; } // Validate subtype if provided if (!string.IsNullOrWhiteSpace(input.Subtype) && !config.SubTypes.Contains(input.Subtype)) { - collector.EmitError(string.Empty, $"Subtype '{input.Subtype}' is not in the list of available subtypes. Available subtypes: {string.Join(", ", config.SubTypes)}"); + collector.EmitError( + string.Empty, + $"Subtype '{input.Subtype}' is not in the list of available subtypes. Available subtypes: {string.Join(", ", config.SubTypes)}" + ); return false; } @@ -148,7 +165,10 @@ public bool ValidateAgainstConfiguration( { foreach (var area in input.Areas.Where(area => !config.Areas.Contains(area))) { - collector.EmitError(string.Empty, $"Area '{area}' is not in the list of available areas. Available areas: {string.Join(", ", config.Areas)}"); + collector.EmitError( + string.Empty, + $"Area '{area}' is not in the list of available areas. Available areas: {string.Join(", ", config.Areas)}" + ); return false; } } @@ -162,7 +182,10 @@ public bool ValidateAgainstConfiguration( if (!validProductIds.Contains(normalizedProductId)) { var availableProducts = string.Join(", ", validProductIds.OrderBy(p => p)); - collector.EmitError(string.Empty, $"Product '{product.Product}' is not in the list of available products from config/products.yml. Available products: {availableProducts}"); + collector.EmitError( + string.Empty, + $"Product '{product.Product}' is not in the list of available products from config/products.yml. Available products: {availableProducts}" + ); return false; } } @@ -171,10 +194,15 @@ public bool ValidateAgainstConfiguration( var availableLifecycleStrings = config.Lifecycles.Select(l => l.ToStringFast(true)).ToList(); foreach (var product in input.Products.Where(product => !string.IsNullOrWhiteSpace(product.Lifecycle))) { - if (!LifecycleExtensions.TryParse(product.Lifecycle, out _, ignoreCase: true, allowMatchingMetadataAttribute: true) - || !availableLifecycleStrings.Contains(product.Lifecycle, StringComparer.OrdinalIgnoreCase)) + if ( + !LifecycleExtensions.TryParse(product.Lifecycle, out _, ignoreCase: true, allowMatchingMetadataAttribute: true) || + !availableLifecycleStrings.Contains(product.Lifecycle, StringComparer.OrdinalIgnoreCase) + ) { - collector.EmitError(string.Empty, $"Lifecycle '{product.Lifecycle}' for product '{product.Product}' is not in the list of available lifecycles. Available lifecycles: {string.Join(", ", availableLifecycleStrings)}"); + collector.EmitError( + string.Empty, + $"Lifecycle '{product.Lifecycle}' for product '{product.Product}' is not in the list of available lifecycles. Available lifecycles: {string.Join(", ", availableLifecycleStrings)}" + ); return false; } } diff --git a/src/services/Elastic.Changelog/Creation/IssueInfoProcessor.cs b/src/services/Elastic.Changelog/Creation/IssueInfoProcessor.cs index 30cb147bc1..e62a7b6092 100644 --- a/src/services/Elastic.Changelog/Creation/IssueInfoProcessor.cs +++ b/src/services/Elastic.Changelog/Creation/IssueInfoProcessor.cs @@ -23,18 +23,18 @@ public async Task ProcessIssueAsync( CreateChangelogArguments input, ChangelogConfiguration config, string issueUrl, - Cancel ctx) + Cancel ctx + ) { var issueInfo = await TryFetchIssueInfoAsync(issueUrl, input.Owner, input.Repo, ctx); if (issueInfo == null) { - collector.EmitWarning(string.Empty, $"Failed to fetch issue information from GitHub for issue: {issueUrl}. Generating basic changelog with provided values."); - return new IssueProcessingResult - { - FetchFailed = true, - ShouldSkip = false - }; + collector.EmitWarning( + string.Empty, + $"Failed to fetch issue information from GitHub for issue: {issueUrl}. Generating basic changelog with provided values." + ); + return new IssueProcessingResult { FetchFailed = true, ShouldSkip = false }; } // Pre-derive products from labels for accurate blocker check when no products were explicitly provided @@ -44,22 +44,12 @@ public async Task ProcessIssueAsync( if (ShouldSkipIssueDueToLabelBlockers(issueInfo.Labels.ToArray(), effectiveProducts, config, collector, issueUrl)) { - return new IssueProcessingResult - { - FetchFailed = false, - ShouldSkip = true - }; + return new IssueProcessingResult { FetchFailed = false, ShouldSkip = true }; } var derivedFields = DeriveFieldsFromIssue(collector, input, config, issueInfo, issueUrl); - return new IssueProcessingResult - { - FetchFailed = false, - ShouldSkip = false, - DerivedFields = derivedFields, - IssueInfo = issueInfo - }; + return new IssueProcessingResult { FetchFailed = false, ShouldSkip = false, DerivedFields = derivedFields, IssueInfo = issueInfo }; } /// @@ -72,13 +62,17 @@ public async Task ProcessIssueAsync( string? repo, IReadOnlyList products, ChangelogConfiguration config, - Cancel ctx) + Cancel ctx + ) { var issueInfo = await TryFetchIssueInfoAsync(issueUrl, owner, repo, ctx); if (issueInfo == null) { - collector.EmitWarning(string.Empty, $"Failed to fetch issue information from GitHub for issue: {issueUrl}. Generating basic changelog with provided values."); + collector.EmitWarning( + string.Empty, + $"Failed to fetch issue information from GitHub for issue: {issueUrl}. Generating basic changelog with provided values." + ); return (false, null); } @@ -96,7 +90,8 @@ public async Task ProcessIssueAsync( CreateChangelogArguments input, ChangelogConfiguration config, GitHubIssueInfo issueInfo, - string issueUrl) + string issueUrl + ) { var derived = new DerivedPrFields(); @@ -114,7 +109,10 @@ public async Task ProcessIssueAsync( { if (string.IsNullOrWhiteSpace(issueInfo.Title)) { - collector.EmitError(string.Empty, $"Issue {issueUrl} does not have a title. Please provide --title or ensure the issue has a title."); + collector.EmitError( + string.Empty, + $"Issue {issueUrl} does not have a title. Please provide --title or ensure the issue has a title." + ); return null; } @@ -131,7 +129,10 @@ public async Task ProcessIssueAsync( { if (config.LabelToType == null || config.LabelToType.Count == 0) { - collector.EmitError(string.Empty, $"Cannot derive type from issue {issueUrl} labels: no type mapping configured in changelog.yml. Please provide --type or configure pivot.types in changelog.yml."); + collector.EmitError( + string.Empty, + $"Cannot derive type from issue {issueUrl} labels: no type mapping configured in changelog.yml. Please provide --type or configure pivot.types in changelog.yml." + ); return null; } @@ -139,7 +140,10 @@ public async Task ProcessIssueAsync( if (mappedType == null) { var availableLabels = issueInfo.Labels.Count > 0 ? string.Join(", ", issueInfo.Labels) : "none"; - collector.EmitError(string.Empty, $"Cannot derive type from issue {issueUrl} labels ({availableLabels}). No matching label found in type mapping. Please provide --type or add pivot.types with labels in changelog.yml."); + collector.EmitError( + string.Empty, + $"Cannot derive type from issue {issueUrl} labels ({availableLabels}). No matching label found in type mapping. Please provide --type or add pivot.types with labels in changelog.yml." + ); return null; } derived.Type = mappedType; @@ -162,8 +166,7 @@ public async Task ProcessIssueAsync( if (input.Highlight == null && config.HighlightLabels is { Count: > 0 }) { - var hasHighlightLabel = issueInfo.Labels.Any(label => - config.HighlightLabels.Contains(label, StringComparer.OrdinalIgnoreCase)); + var hasHighlightLabel = issueInfo.Labels.Any(label => config.HighlightLabels.Contains(label, StringComparer.OrdinalIgnoreCase)); if (hasHighlightLabel) { derived.Highlight = true; @@ -174,9 +177,7 @@ public async Task ProcessIssueAsync( logger.LogDebug("Using explicitly provided highlight value, ignoring issue labels"); // Include the current issue in Issues array - derived.Issues = input.Issues is { Length: > 0 } - ? input.Issues - : [issueUrl]; + derived.Issues = input.Issues is { Length: > 0 } ? input.Issues : [issueUrl]; // Map labels to products if products were not explicitly provided if (input.Products.Count == 0 && config.LabelToProducts != null) @@ -185,7 +186,10 @@ public async Task ProcessIssueAsync( if (mappedProducts.Count > 0) { derived.Products = mappedProducts; - logger.LogInformation("Mapped issue labels to products: {Products}", string.Join(", ", mappedProducts.Select(p => p.Product))); + logger.LogInformation( + "Mapped issue labels to products: {Products}", + string.Join(", ", mappedProducts.Select(p => p.Product)) + ); } } else if (input.Products.Count > 0) @@ -208,8 +212,11 @@ public async Task ProcessIssueAsync( if ((input.ExtractIssues ?? false) && issueInfo.LinkedPrs.Count > 0) { derived.Prs = issueInfo.LinkedPrs.ToArray(); - logger.LogInformation("Extracted {Count} linked PRs from issue body: {Prs}", - issueInfo.LinkedPrs.Count, string.Join(", ", issueInfo.LinkedPrs)); + logger.LogInformation( + "Extracted {Count} linked PRs from issue body: {Prs}", + issueInfo.LinkedPrs.Count, + string.Join(", ", issueInfo.LinkedPrs) + ); } return derived; @@ -220,7 +227,8 @@ private bool ShouldSkipIssueDueToLabelBlockers( IReadOnlyList products, ChangelogConfiguration config, IDiagnosticsCollector collector, - string issueUrl) + string issueUrl + ) { var createRules = config.Rules?.Create; if (createRules == null) @@ -261,10 +269,7 @@ private bool ShouldSkipIssueDueToLabelBlockers( } catch (Exception ex) { - if (ex is OutOfMemoryException or - StackOverflowException or - AccessViolationException or - ThreadAbortException) + if (ex is OutOfMemoryException or StackOverflowException or AccessViolationException or ThreadAbortException) throw; logger.LogWarning(ex, "Error fetching issue information from GitHub. Continuing with provided values."); return null; diff --git a/src/services/Elastic.Changelog/Creation/PrInfoProcessor.cs b/src/services/Elastic.Changelog/Creation/PrInfoProcessor.cs index d13a76c61a..a082b7a58a 100644 --- a/src/services/Elastic.Changelog/Creation/PrInfoProcessor.cs +++ b/src/services/Elastic.Changelog/Creation/PrInfoProcessor.cs @@ -23,18 +23,18 @@ public async Task ProcessPrAsync( CreateChangelogArguments input, ChangelogConfiguration config, string prUrl, - Cancel ctx) + Cancel ctx + ) { var prInfo = await TryFetchPrInfoAsync(prUrl, input.Owner, input.Repo, ctx); if (prInfo == null) { - collector.EmitWarning(string.Empty, $"Failed to fetch PR information from GitHub for PR: {prUrl}. Generating basic changelog with provided values."); - return new PrProcessingResult - { - FetchFailed = true, - ShouldSkip = false - }; + collector.EmitWarning( + string.Empty, + $"Failed to fetch PR information from GitHub for PR: {prUrl}. Generating basic changelog with provided values." + ); + return new PrProcessingResult { FetchFailed = true, ShouldSkip = false }; } // Pre-derive products from labels for accurate blocker check when no products were explicitly provided @@ -45,23 +45,13 @@ public async Task ProcessPrAsync( // Check for label blockers using effective products (including label-derived ones) if (ShouldSkipPrDueToLabelBlockers(prInfo.Labels.ToArray(), effectiveProducts, config, collector, prUrl)) { - return new PrProcessingResult - { - FetchFailed = false, - ShouldSkip = true - }; + return new PrProcessingResult { FetchFailed = false, ShouldSkip = true }; } // Process PR info and derive fields var derivedFields = DeriveFieldsFromPr(collector, input, config, prInfo, prUrl); - return new PrProcessingResult - { - FetchFailed = false, - ShouldSkip = false, - DerivedFields = derivedFields, - PrInfo = prInfo - }; + return new PrProcessingResult { FetchFailed = false, ShouldSkip = false, DerivedFields = derivedFields, PrInfo = prInfo }; } /// @@ -74,13 +64,17 @@ public async Task ProcessPrAsync( string? repo, IReadOnlyList products, ChangelogConfiguration config, - Cancel ctx) + Cancel ctx + ) { var prInfo = await TryFetchPrInfoAsync(prUrl, owner, repo, ctx); if (prInfo == null) { - collector.EmitWarning(string.Empty, $"Failed to fetch PR information from GitHub for PR: {prUrl}. Generating basic changelog with provided values."); + collector.EmitWarning( + string.Empty, + $"Failed to fetch PR information from GitHub for PR: {prUrl}. Generating basic changelog with provided values." + ); return (false, null); } @@ -98,7 +92,8 @@ public async Task ProcessPrAsync( CreateChangelogArguments input, ChangelogConfiguration config, GitHubPrInfo prInfo, - string prUrl) + string prUrl + ) { var derived = new DerivedPrFields(); @@ -118,7 +113,10 @@ public async Task ProcessPrAsync( { if (string.IsNullOrWhiteSpace(prInfo.Title)) { - collector.EmitError(string.Empty, $"PR {prUrl} does not have a title. Please provide --title or ensure the PR has a title."); + collector.EmitError( + string.Empty, + $"PR {prUrl} does not have a title. Please provide --title or ensure the PR has a title." + ); return null; } @@ -137,7 +135,10 @@ public async Task ProcessPrAsync( { if (config.LabelToType == null || config.LabelToType.Count == 0) { - collector.EmitError(string.Empty, $"Cannot derive type from PR {prUrl} labels: no type mapping configured in changelog.yml. Please provide --type or configure pivot.types in changelog.yml."); + collector.EmitError( + string.Empty, + $"Cannot derive type from PR {prUrl} labels: no type mapping configured in changelog.yml. Please provide --type or configure pivot.types in changelog.yml." + ); return null; } @@ -145,7 +146,10 @@ public async Task ProcessPrAsync( if (mappedType == null) { var availableLabels = prInfo.Labels.Count > 0 ? string.Join(", ", prInfo.Labels) : "none"; - collector.EmitError(string.Empty, $"Cannot derive type from PR {prUrl} labels ({availableLabels}). No matching label found in type mapping. Please provide --type or add pivot.types with labels in changelog.yml."); + collector.EmitError( + string.Empty, + $"Cannot derive type from PR {prUrl} labels ({availableLabels}). No matching label found in type mapping. Please provide --type or add pivot.types with labels in changelog.yml." + ); return null; } derived.Type = mappedType; @@ -170,8 +174,7 @@ public async Task ProcessPrAsync( // Check highlight labels if CLI highlight not set if (input.Highlight == null && config.HighlightLabels is { Count: > 0 }) { - var hasHighlightLabel = prInfo.Labels.Any(label => - config.HighlightLabels.Contains(label, StringComparer.OrdinalIgnoreCase)); + var hasHighlightLabel = prInfo.Labels.Any(label => config.HighlightLabels.Contains(label, StringComparer.OrdinalIgnoreCase)); if (hasHighlightLabel) { derived.Highlight = true; @@ -213,8 +216,11 @@ public async Task ProcessPrAsync( if (prInfo.LinkedIssues.Count > 0) { derived.Issues = prInfo.LinkedIssues.ToArray(); - logger.LogInformation("Extracted {Count} linked issues from PR body: {Issues}", - prInfo.LinkedIssues.Count, string.Join(", ", prInfo.LinkedIssues)); + logger.LogInformation( + "Extracted {Count} linked issues from PR body: {Issues}", + prInfo.LinkedIssues.Count, + string.Join(", ", prInfo.LinkedIssues) + ); } } else if (input.Issues is { Length: > 0 }) @@ -228,7 +234,8 @@ internal static bool ShouldSkipPrDueToLabelBlockers( IReadOnlyList products, ChangelogConfiguration config, IDiagnosticsCollector collector, - string prUrl) + string prUrl + ) { var createRules = config.Rules?.Create; if (createRules == null) @@ -261,7 +268,8 @@ internal static bool ShouldSkipByCreateRules( CreateRules rules, IDiagnosticsCollector collector, string prUrl, - string? productContext) + string? productContext + ) { if (rules.Labels == null || rules.Labels.Count == 0) return false; @@ -276,18 +284,23 @@ internal static bool ShouldSkipByCreateRules( // Exclude mode: skip if any/all/conjunction labels match var matchingLabel = match switch { - MatchMode.All => prLabels.All(label => rules.Labels.Contains(label, StringComparer.OrdinalIgnoreCase)) - ? string.Join(", ", prLabels) - : null, - MatchMode.Conjunction => rules.Labels.All(blockerLabel => prLabels.Contains(blockerLabel, StringComparer.OrdinalIgnoreCase)) - ? string.Join(", ", rules.Labels) - : null, + MatchMode.All => + prLabels.All(label => rules.Labels.Contains(label, StringComparer.OrdinalIgnoreCase)) + ? string.Join(", ", prLabels) + : null, + MatchMode.Conjunction => + rules.Labels.All(blockerLabel => prLabels.Contains(blockerLabel, StringComparer.OrdinalIgnoreCase)) + ? string.Join(", ", rules.Labels) + : null, _ => rules.Labels.FirstOrDefault(blockerLabel => prLabels.Contains(blockerLabel, StringComparer.OrdinalIgnoreCase)) }; if (matchingLabel != null) { - collector.EmitWarning(string.Empty, $"{prefix} Skipping changelog creation for PR {prUrl} due to blocking label '{matchingLabel}'{productSuffix} (match: {match.ToString().ToLowerInvariant()})."); + collector.EmitWarning( + string.Empty, + $"{prefix} Skipping changelog creation for PR {prUrl} due to blocking label '{matchingLabel}'{productSuffix} (match: {match.ToString().ToLowerInvariant()})." + ); return true; } } @@ -304,7 +317,10 @@ internal static bool ShouldSkipByCreateRules( if (!hasMatch) { var labelsList = string.Join(", ", rules.Labels); - collector.EmitWarning(string.Empty, $"{prefix} Skipping changelog creation for PR {prUrl}, no labels match rules.create.include [{labelsList}]{productSuffix} (match: {match.ToString().ToLowerInvariant()})."); + collector.EmitWarning( + string.Empty, + $"{prefix} Skipping changelog creation for PR {prUrl}, no labels match rules.create.include [{labelsList}]{productSuffix} (match: {match.ToString().ToLowerInvariant()})." + ); return true; } } @@ -328,10 +344,7 @@ internal static bool ShouldSkipByCreateRules( } catch (Exception ex) { - if (ex is OutOfMemoryException or - StackOverflowException or - AccessViolationException or - ThreadAbortException) + if (ex is OutOfMemoryException or StackOverflowException or AccessViolationException or ThreadAbortException) throw; logger.LogWarning(ex, "Error fetching PR information from GitHub. Continuing with provided values."); return null; @@ -393,9 +406,10 @@ internal static bool IsBlockedByRules(string[] prLabels, CreateRules rules) return rules.Mode == FieldMode.Exclude ? labelsMatch : !labelsMatch; } - internal static string? MapLabelsToType(string[] labels, IReadOnlyDictionary labelToTypeMapping) => labels - .Select(label => labelToTypeMapping.TryGetValue(label, out var mappedType) ? mappedType : null) - .FirstOrDefault(mappedType => mappedType != null); + internal static string? MapLabelsToType(string[] labels, IReadOnlyDictionary labelToTypeMapping) => + labels.Select(label => labelToTypeMapping.TryGetValue(label, out var mappedType) ? mappedType : null).FirstOrDefault( + mappedType => mappedType != null + ); internal static List MapLabelsToAreas(string[] labels, IReadOnlyDictionary> labelToAreasMapping) { @@ -450,7 +464,8 @@ internal static List MapLabelsToProducts(string[] labels, IRead internal static string? MapLabelsToFeatureId( string[] labels, IReadOnlyDictionary labelToFeaturesMapping, - IDiagnosticsCollector collector) + IDiagnosticsCollector collector + ) { var seen = new HashSet(StringComparer.OrdinalIgnoreCase); var featureIds = new List(); @@ -471,8 +486,10 @@ internal static List MapLabelsToProducts(string[] labels, IRead if (featureIds.Count > 1) { - collector.EmitWarning(string.Empty, - $"Multiple feature-id values matched from labels ({string.Join(", ", featureIds)}). Using first match '{featureIds[0]}'. Provide --feature-id to override."); + collector.EmitWarning( + string.Empty, + $"Multiple feature-id values matched from labels ({string.Join(", ", featureIds)}). Using first match '{featureIds[0]}'. Provide --feature-id to override." + ); } return featureIds[0]; diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs index 7ea408b3ca..6508d27ef9 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs @@ -33,7 +33,8 @@ public async Task EvaluateArtifact(IDiagnosticsCollector collector, Evalua try { var artifactMetadataJson = await _fileSystem.File.ReadAllTextAsync(input.MetadataPath, ctx); - metadata = JsonSerializer.Deserialize(artifactMetadataJson, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + metadata = + JsonSerializer.Deserialize(artifactMetadataJson, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); } catch (FileNotFoundException) { @@ -61,9 +62,8 @@ public async Task EvaluateArtifact(IDiagnosticsCollector collector, Evalua return false; } - var prInfo = await gitHubPrService.FetchPrInfoAsync( - metadata.PrNumber.ToString(CultureInfo.InvariantCulture), input.Owner, input.Repo, ctx - ); + var prInfo = + await gitHubPrService.FetchPrInfoAsync(metadata.PrNumber.ToString(CultureInfo.InvariantCulture), input.Owner, input.Repo, ctx); if (prInfo is null) { collector.EmitError(input.MetadataPath, $"Failed to fetch PR #{metadata.PrNumber} from GitHub"); @@ -72,8 +72,11 @@ public async Task EvaluateArtifact(IDiagnosticsCollector collector, Evalua if (!string.Equals(prInfo.HeadSha, metadata.HeadSha, StringComparison.OrdinalIgnoreCase)) { - _logger.LogInformation("PR head has moved ({OldSha} → {NewSha}), skipping — newer run will handle it", - metadata.HeadSha, prInfo.HeadSha); + _logger.LogInformation( + "PR head has moved ({OldSha} → {NewSha}), skipping — newer run will handle it", + metadata.HeadSha, + prInfo.HeadSha + ); return true; } @@ -84,7 +87,10 @@ public async Task EvaluateArtifact(IDiagnosticsCollector collector, Evalua } var statusParsed = PrEvaluationResultExtensions.TryParse( - metadata.Status, out var metadataStatus, ignoreCase: true, allowMatchingMetadataAttribute: true + metadata.Status, + out var metadataStatus, + ignoreCase: true, + allowMatchingMetadataAttribute: true ); var shouldCommit = statusParsed && metadataStatus == PrEvaluationResult.Success && metadata.CanCommit; @@ -101,19 +107,41 @@ public async Task EvaluateArtifact(IDiagnosticsCollector collector, Evalua await coreService.SetOutputAsync("status", OutputSanitizer.SanitizeForOutput(metadata.Status, OutputSanitizer.TypeMaxLength)); await coreService.SetOutputAsync("is-fork", metadata.IsFork ? "true" : "false"); await coreService.SetOutputAsync("head-repo", OutputSanitizer.SanitizeForOutput(metadata.HeadRepo, OutputSanitizer.PathMaxLength)); - await coreService.SetOutputAsync("config-file", OutputSanitizer.SanitizeForOutput(metadata.ConfigFile, OutputSanitizer.PathMaxLength)); - await coreService.SetOutputAsync("changelog-dir", OutputSanitizer.SanitizeForOutput(metadata.ChangelogDir, OutputSanitizer.PathMaxLength)); - await coreService.SetOutputAsync("changelog-filename", OutputSanitizer.SanitizeForOutput(metadata.ChangelogFilename, OutputSanitizer.PathMaxLength)); - await coreService.SetOutputAsync("label-table", OutputSanitizer.SanitizeForOutput(metadata.LabelTable, OutputSanitizer.LabelTableMaxLength)); - await coreService.SetOutputAsync("product-label-table", OutputSanitizer.SanitizeForOutput(metadata.ProductLabelTable, OutputSanitizer.LabelTableMaxLength)); - await coreService.SetOutputAsync("skip-labels", OutputSanitizer.SanitizeForOutput(metadata.SkipLabels, OutputSanitizer.LabelsMaxLength)); + await coreService.SetOutputAsync( + "config-file", + OutputSanitizer.SanitizeForOutput(metadata.ConfigFile, OutputSanitizer.PathMaxLength) + ); + await coreService.SetOutputAsync( + "changelog-dir", + OutputSanitizer.SanitizeForOutput(metadata.ChangelogDir, OutputSanitizer.PathMaxLength) + ); + await coreService.SetOutputAsync( + "changelog-filename", + OutputSanitizer.SanitizeForOutput(metadata.ChangelogFilename, OutputSanitizer.PathMaxLength) + ); + await coreService.SetOutputAsync( + "label-table", + OutputSanitizer.SanitizeForOutput(metadata.LabelTable, OutputSanitizer.LabelTableMaxLength) + ); + await coreService.SetOutputAsync( + "product-label-table", + OutputSanitizer.SanitizeForOutput(metadata.ProductLabelTable, OutputSanitizer.LabelTableMaxLength) + ); + await coreService.SetOutputAsync( + "skip-labels", + OutputSanitizer.SanitizeForOutput(metadata.SkipLabels, OutputSanitizer.LabelsMaxLength) + ); await coreService.SetOutputAsync("should-commit", shouldCommit ? "true" : "false"); await coreService.SetOutputAsync("should-comment-success", shouldCommentSuccess ? "true" : "false"); await coreService.SetOutputAsync("should-comment-failure", shouldCommentFailure ? "true" : "false"); _logger.LogInformation( "Artifact evaluation complete: status={Status}, commit={Commit}, commentSuccess={CommentSuccess}, commentFailure={CommentFailure}", - metadata.Status, shouldCommit, shouldCommentSuccess, shouldCommentFailure); + metadata.Status, + shouldCommit, + shouldCommentSuccess, + shouldCommentFailure + ); return true; } diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactMetadata.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactMetadata.cs index dede86bf8a..2440bea4d3 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactMetadata.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactMetadata.cs @@ -28,11 +28,7 @@ public record ChangelogArtifactMetadata public CreateRules? CreateRules { get; init; } } -[JsonSourceGenerationOptions( - WriteIndented = true, - UseStringEnumConverter = true, - PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower -)] +[JsonSourceGenerationOptions(WriteIndented = true, UseStringEnumConverter = true, PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)] [JsonSerializable(typeof(ChangelogArtifactMetadata))] [JsonSerializable(typeof(CreateRules))] [JsonSerializable(typeof(FieldMode))] diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrBodyReader.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrBodyReader.cs index 5a33e224fb..1d5033d305 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogPrBodyReader.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrBodyReader.cs @@ -24,7 +24,8 @@ public static class ChangelogPrBodyReader string? prBodyFile, IDiagnosticsCollector collector, IRunnerTempFileSystem fileSystem, - CancellationToken ct) + CancellationToken ct + ) { if (string.IsNullOrWhiteSpace(prBodyFile)) return null; diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs index b572df850a..f06f754316 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs @@ -40,8 +40,7 @@ public async Task EvaluatePr(IDiagnosticsCollector collector, EvaluatePrAr return await SetOutputs(PrEvaluationResult.Skipped); } - var config = await _configLoader.LoadChangelogConfiguration(collector, input.Config, ctx) - ?? ChangelogConfiguration.Default; + var config = await _configLoader.LoadChangelogConfiguration(collector, input.Config, ctx) ?? ChangelogConfiguration.Default; var changelogDir = config.Bundle?.Directory ?? "docs/changelog"; // Commit bot loop detection @@ -57,16 +56,13 @@ public async Task EvaluatePr(IDiagnosticsCollector collector, EvaluatePrAr // Find existing changelog file for this PR (handles all filename strategies) var existingFilename = FindExistingChangelog(changelogDir, input.PrNumber); - var changelogFilePath = existingFilename != null - ? $"{changelogDir}/{existingFilename}" - : null; + var changelogFilePath = existingFilename != null ? $"{changelogDir}/{existingFilename}" : null; // Manual edit detection (only if a file exists) if (changelogFilePath != null) { - var fileAuthor = await gitHubPrService.FetchLastFileCommitAuthorAsync( - input.Owner, input.Repo, changelogFilePath, input.HeadRef, ctx - ); + var fileAuthor = + await gitHubPrService.FetchLastFileCommitAuthorAsync(input.Owner, input.Repo, changelogFilePath, input.HeadRef, ctx); if (!string.IsNullOrEmpty(fileAuthor) && !string.Equals(fileAuthor, input.BotName, StringComparison.OrdinalIgnoreCase)) { _logger.LogInformation("Skipping: changelog file {File} manually edited by {Author}", changelogFilePath, fileAuthor); @@ -128,14 +124,10 @@ public async Task EvaluatePr(IDiagnosticsCollector collector, EvaluatePrAr { // When only one distinct product is configured, assigning it implicitly is // unambiguous — no point requiring contributors to add a redundant label. - var distinctSpecs = labelToProducts.Values - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); + var distinctSpecs = labelToProducts.Values.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); if (distinctSpecs.Count == 1) { - resolvedProducts = ProductArgument.FormatProductSpecs( - ProductArgument.ParseProductSpecs(distinctSpecs[0]) - ); + resolvedProducts = ProductArgument.FormatProductSpecs(ProductArgument.ParseProductSpecs(distinctSpecs[0])); _logger.LogInformation("Single product configured; assigning implicitly: {Products}", resolvedProducts); } else @@ -146,14 +138,19 @@ public async Task EvaluatePr(IDiagnosticsCollector collector, EvaluatePrAr if (resolvedType == null) { _logger.LogInformation("No type label found on PR"); - collector.EmitError(string.Empty, "No matching changelog type label found on this PR. Add a label from your changelog.yml pivot.types, or a skip label."); - _ = await SetOutputs( - PrEvaluationResult.NoLabel, title, - resolvedDescription: description, - labelTable: BuildLabelTable(config.LabelToType), - productLabelTable: productLabelTable, - skipLabels: skipLabels + collector.EmitError( + string.Empty, + "No matching changelog type label found on this PR. Add a label from your changelog.yml pivot.types, or a skip label." ); + _ = + await SetOutputs( + PrEvaluationResult.NoLabel, + title, + resolvedDescription: description, + labelTable: BuildLabelTable(config.LabelToType), + productLabelTable: productLabelTable, + skipLabels: skipLabels + ); return false; } @@ -161,23 +158,35 @@ public async Task EvaluatePr(IDiagnosticsCollector collector, EvaluatePrAr // available to fill in via inference at 'changelog add' time. Surface this as a // missing-label failure so the contributor sees an actionable hint instead of a hard // error later in the workflow. - if (productLabelTable != null - && (config.ProductsConfiguration?.Default is null or { Count: 0 })) + if (productLabelTable != null && (config.ProductsConfiguration?.Default is null or { Count: 0 })) { _logger.LogInformation("Multiple products configured but no matching product label on PR; no default products configured"); - collector.EmitError(string.Empty, "No matching product label found on this PR. Add a label from your changelog.yml pivot.products."); - _ = await SetOutputs( - PrEvaluationResult.NoLabel, title, - resolvedDescription: description, - productLabelTable: productLabelTable, - skipLabels: skipLabels + collector.EmitError( + string.Empty, + "No matching product label found on this PR. Add a label from your changelog.yml pivot.products." ); + _ = + await SetOutputs( + PrEvaluationResult.NoLabel, + title, + resolvedDescription: description, + productLabelTable: productLabelTable, + skipLabels: skipLabels + ); return false; } - _logger.LogInformation("PR evaluation complete: title={Title}, type={Type}, products={Products}, existingFile={File}", title, resolvedType, resolvedProducts, existingFilename); + _logger.LogInformation( + "PR evaluation complete: title={Title}, type={Type}, products={Products}, existingFile={File}", + title, + resolvedType, + resolvedProducts, + existingFilename + ); return await SetOutputs( - PrEvaluationResult.Success, title, resolvedType, + PrEvaluationResult.Success, + title, + resolvedType, resolvedDescription: description, resolvedProducts: resolvedProducts, productLabelTable: productLabelTable, @@ -199,11 +208,10 @@ private async Task SetOutputs( string? productLabelTable = null, string? changelogDir = null, string? existingFilename = null, - string? skipLabels = null) + string? skipLabels = null + ) { - var statusString = status == PrEvaluationResult.Success - ? ProceedStatus - : status.ToStringFast(true); + var statusString = status == PrEvaluationResult.Success ? ProceedStatus : status.ToStringFast(true); var shouldGenerate = status == PrEvaluationResult.Success; @@ -216,19 +224,37 @@ private async Task SetOutputs( if (resolvedTitle != null) await coreService.SetOutputAsync("title", OutputSanitizer.SanitizeForOutput(resolvedTitle, OutputSanitizer.TitleMaxLength)); if (resolvedDescription != null) - await coreService.SetOutputAsync("description", OutputSanitizer.SanitizeForOutput(resolvedDescription, OutputSanitizer.DescriptionMaxLength)); + await coreService.SetOutputAsync( + "description", + OutputSanitizer.SanitizeForOutput(resolvedDescription, OutputSanitizer.DescriptionMaxLength) + ); if (resolvedType != null) await coreService.SetOutputAsync("type", OutputSanitizer.SanitizeForOutput(resolvedType, OutputSanitizer.TypeMaxLength)); if (resolvedProducts != null) - await coreService.SetOutputAsync("products", OutputSanitizer.SanitizeForOutput(resolvedProducts, OutputSanitizer.LabelsMaxLength)); + await coreService.SetOutputAsync( + "products", + OutputSanitizer.SanitizeForOutput(resolvedProducts, OutputSanitizer.LabelsMaxLength) + ); if (labelTable != null) - await coreService.SetOutputAsync("label-table", OutputSanitizer.SanitizeForOutput(labelTable, OutputSanitizer.LabelTableMaxLength)); + await coreService.SetOutputAsync( + "label-table", + OutputSanitizer.SanitizeForOutput(labelTable, OutputSanitizer.LabelTableMaxLength) + ); if (productLabelTable != null) - await coreService.SetOutputAsync("product-label-table", OutputSanitizer.SanitizeForOutput(productLabelTable, OutputSanitizer.LabelTableMaxLength)); + await coreService.SetOutputAsync( + "product-label-table", + OutputSanitizer.SanitizeForOutput(productLabelTable, OutputSanitizer.LabelTableMaxLength) + ); if (changelogDir != null) - await coreService.SetOutputAsync("changelog-dir", OutputSanitizer.SanitizeForOutput(changelogDir, OutputSanitizer.PathMaxLength)); + await coreService.SetOutputAsync( + "changelog-dir", + OutputSanitizer.SanitizeForOutput(changelogDir, OutputSanitizer.PathMaxLength) + ); if (existingFilename != null) - await coreService.SetOutputAsync("existing-changelog-filename", OutputSanitizer.SanitizeForOutput(existingFilename, OutputSanitizer.PathMaxLength)); + await coreService.SetOutputAsync( + "existing-changelog-filename", + OutputSanitizer.SanitizeForOutput(existingFilename, OutputSanitizer.PathMaxLength) + ); if (skipLabels != null) await coreService.SetOutputAsync("skip-labels", OutputSanitizer.SanitizeForOutput(skipLabels, OutputSanitizer.LabelsMaxLength)); @@ -292,9 +318,9 @@ private async Task SetOutputs( } internal static bool ContentReferencesPr(string content, string prNumber) => - content.Contains($"/pull/{prNumber}", StringComparison.Ordinal) || - content.Contains($"- \"{prNumber}\"", StringComparison.Ordinal) || - content.Contains($"- '{prNumber}'", StringComparison.Ordinal); + content.Contains($"/pull/{prNumber}", StringComparison.Ordinal) + || content.Contains($"- \"{prNumber}\"", StringComparison.Ordinal) + || content.Contains($"- '{prNumber}'", StringComparison.Ordinal); internal static string BuildLabelTable(IReadOnlyDictionary? labelToType) => BuildMappingTable(labelToType, "Label", "Type"); diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs index 0773c20ce6..da810fea6a 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs @@ -36,8 +36,12 @@ IRunnerTempFileSystem fileSystem public async Task PrepareArtifact(IDiagnosticsCollector collector, PrepareArtifactArguments input, Cancel ctx) { var status = ResolveStatus(input.EvaluateStatus, input.GenerateOutcome); - _logger.LogInformation("Resolved artifact status: {Status} (evaluate={Evaluate}, generate={Generate})", - status, input.EvaluateStatus, input.GenerateOutcome); + _logger.LogInformation( + "Resolved artifact status: {Status} (evaluate={Evaluate}, generate={Generate})", + status, + input.EvaluateStatus, + input.GenerateOutcome + ); _ = _fileSystem.Directory.CreateDirectory(input.OutputDir); diff --git a/src/services/Elastic.Changelog/GitHub/GitHubCommitRangeService.cs b/src/services/Elastic.Changelog/GitHub/GitHubCommitRangeService.cs index be6ecddee6..27128b6f98 100644 --- a/src/services/Elastic.Changelog/GitHub/GitHubCommitRangeService.cs +++ b/src/services/Elastic.Changelog/GitHub/GitHubCommitRangeService.cs @@ -18,8 +18,10 @@ namespace Elastic.Changelog.GitHub; /// (commit → PR association). Works for squash and merge commits on protected integration branches; /// commits with no associated merged PR are reported, not silently dropped. /// -public sealed partial class GitHubCommitRangeService(ILoggerFactory logFactory, GitHubApiTransport? transport = null) - : IGitHubCommitRangeService +public sealed partial class GitHubCommitRangeService( + ILoggerFactory logFactory, + GitHubApiTransport? transport = null +) : IGitHubCommitRangeService { private const int ComparePageSize = 100; private const int GraphQlBatchSize = 50; @@ -38,21 +40,26 @@ public sealed partial class GitHubCommitRangeService(ILoggerFactory logFactory, public async Task ResolvePullRequestsAsync( IDiagnosticsCollector collector, CommitRangeArguments args, - Cancel ctx) + Cancel ctx + ) { var token = _transport.ResolveToken(); if (string.IsNullOrWhiteSpace(token)) { - collector.EmitError(string.Empty, + collector.EmitError( + string.Empty, "Resolving pull requests from a commit range requires GitHub credentials. " + - "Set the GITHUB_TOKEN environment variable (the GraphQL API used for commit→PR association does not accept anonymous requests)."); + "Set the GITHUB_TOKEN environment variable (the GraphQL API used for commit→PR association does not accept anonymous requests)." + ); return null; } if (!SafeGraphQlIdentifierRegex().IsMatch(args.Owner) || !SafeGraphQlIdentifierRegex().IsMatch(args.Repo)) { - collector.EmitError(string.Empty, - $"Invalid repository '{args.Owner}/{args.Repo}': owner and repo must contain only letters, digits, '.', '_' or '-'."); + collector.EmitError( + string.Empty, + $"Invalid repository '{args.Owner}/{args.Repo}': owner and repo must contain only letters, digits, '.', '_' or '-'." + ); return null; } @@ -62,8 +69,10 @@ public sealed partial class GitHubCommitRangeService(ILoggerFactory logFactory, if (commits.Count == 0) { - collector.EmitWarning(string.Empty, - $"Commit range {args.StartRef}..{args.EndRef} for {args.Owner}/{args.Repo} contains no commits."); + collector.EmitWarning( + string.Empty, + $"Commit range {args.StartRef}..{args.EndRef} for {args.Owner}/{args.Repo} contains no commits." + ); return new CommitRangeResolution { TotalCommits = 0, PullRequests = [], CommitsWithoutPullRequest = [] }; } @@ -77,7 +86,8 @@ public sealed partial class GitHubCommitRangeService(ILoggerFactory logFactory, private async Task?> FetchCompareCommitsAsync( IDiagnosticsCollector collector, CommitRangeArguments args, - Cancel ctx) + Cancel ctx + ) { var basehead = $"{Uri.EscapeDataString(args.StartRef)}...{Uri.EscapeDataString(args.EndRef)}"; var commits = new List(); @@ -92,16 +102,20 @@ public sealed partial class GitHubCommitRangeService(ILoggerFactory logFactory, using var response = await _transport.GetAsync(url, ctx).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.NotFound) { - collector.EmitError(string.Empty, + collector.EmitError( + string.Empty, $"GitHub could not compare {args.StartRef}...{args.EndRef} in {args.Owner}/{args.Repo} (404). " + - "Ensure both refs exist in the repository and the token can read it."); + "Ensure both refs exist in the repository and the token can read it." + ); return null; } if (!response.IsSuccessStatusCode) { - collector.EmitError(string.Empty, - $"GitHub compare request for {args.Owner}/{args.Repo} {args.StartRef}...{args.EndRef} failed: {(int)response.StatusCode} {response.ReasonPhrase}."); + collector.EmitError( + string.Empty, + $"GitHub compare request for {args.Owner}/{args.Repo} {args.StartRef}...{args.EndRef} failed: {(int)response.StatusCode} {response.ReasonPhrase}." + ); return null; } @@ -116,18 +130,24 @@ public sealed partial class GitHubCommitRangeService(ILoggerFactory logFactory, if (page == 1) { totalCommits = compare.TotalCommits; - if (string.Equals(compare.Status, "behind", StringComparison.OrdinalIgnoreCase) || - string.Equals(compare.Status, "identical", StringComparison.OrdinalIgnoreCase)) + if ( + string.Equals(compare.Status, "behind", StringComparison.OrdinalIgnoreCase) || + string.Equals(compare.Status, "identical", StringComparison.OrdinalIgnoreCase) + ) { - collector.EmitWarning(string.Empty, - $"Commit range {args.StartRef}...{args.EndRef} for {args.Owner}/{args.Repo} is '{compare.Status}' — the end ref adds no commits over the start ref."); + collector.EmitWarning( + string.Empty, + $"Commit range {args.StartRef}...{args.EndRef} for {args.Owner}/{args.Repo} is '{compare.Status}' — the end ref adds no commits over the start ref." + ); return []; } if (string.Equals(compare.Status, "diverged", StringComparison.OrdinalIgnoreCase)) { - collector.EmitWarning(string.Empty, - $"Refs {args.StartRef} and {args.EndRef} for {args.Owner}/{args.Repo} have diverged; only commits reachable from {args.EndRef} but not {args.StartRef} are considered."); + collector.EmitWarning( + string.Empty, + $"Refs {args.StartRef} and {args.EndRef} for {args.Owner}/{args.Repo} have diverged; only commits reachable from {args.EndRef} but not {args.StartRef} are considered." + ); } } @@ -143,13 +163,21 @@ public sealed partial class GitHubCommitRangeService(ILoggerFactory logFactory, page++; } - _logger.LogInformation("Compare {Start}...{End} for {Owner}/{Repo}: {Count} commit(s)", - args.StartRef, args.EndRef, args.Owner, args.Repo, commits.Count); + _logger.LogInformation( + "Compare {Start}...{End} for {Owner}/{Repo}: {Count} commit(s)", + args.StartRef, + args.EndRef, + args.Owner, + args.Repo, + commits.Count + ); if (commits.Count < totalCommits) { - collector.EmitError(string.Empty, - $"GitHub compare pagination for {args.Owner}/{args.Repo} returned {commits.Count} of {totalCommits} commits; refusing to resolve a partial range."); + collector.EmitError( + string.Empty, + $"GitHub compare pagination for {args.Owner}/{args.Repo} returned {commits.Count} of {totalCommits} commits; refusing to resolve a partial range." + ); return null; } @@ -164,13 +192,13 @@ public sealed partial class GitHubCommitRangeService(ILoggerFactory logFactory, IDiagnosticsCollector collector, CommitRangeArguments args, IReadOnlyList commits, - Cancel ctx) + Cancel ctx + ) { var invalidShas = commits.Where(sha => !CommitShaRegex().IsMatch(sha)).ToList(); if (invalidShas.Count > 0) { - collector.EmitError(string.Empty, - $"GitHub compare returned malformed commit sha(s): {string.Join(", ", invalidShas)}."); + collector.EmitError(string.Empty, $"GitHub compare returned malformed commit sha(s): {string.Join(", ", invalidShas)}."); return null; } @@ -207,18 +235,20 @@ public sealed partial class GitHubCommitRangeService(ILoggerFactory logFactory, } } - var pullRequests = orderedPrNumbers - .Select(number => new CommitRangePullRequest - { - Number = number, - Url = prsByNumber[number].Url, - CommitShas = prsByNumber[number].Shas - }) - .ToList(); + var pullRequests = orderedPrNumbers.Select( + number => new CommitRangePullRequest { Number = number, Url = prsByNumber[number].Url, CommitShas = prsByNumber[number].Shas } + ).ToList(); _logger.LogInformation( "Resolved {PrCount} pull request(s) from {CommitCount} commit(s) in {Owner}/{Repo} {Start}...{End} ({NoPrCount} commit(s) without an associated PR)", - pullRequests.Count, commits.Count, args.Owner, args.Repo, args.StartRef, args.EndRef, commitsWithoutPr.Count); + pullRequests.Count, + commits.Count, + args.Owner, + args.Repo, + args.StartRef, + args.EndRef, + commitsWithoutPr.Count + ); return new CommitRangeResolution { @@ -238,19 +268,17 @@ public sealed partial class GitHubCommitRangeService(ILoggerFactory logFactory, IDiagnosticsCollector collector, string sha, GraphQlCommit? commitNode, - string repoFullName) + string repoFullName + ) { - var candidates = commitNode?.AssociatedPullRequests?.Nodes? - .OfType() - .Where(pr => pr.Merged && - string.Equals(pr.BaseRepository?.NameWithOwner, repoFullName, StringComparison.OrdinalIgnoreCase)) - .ToList() ?? []; + var candidates = commitNode?.AssociatedPullRequests?.Nodes?.OfType().Where( + pr => pr.Merged && string.Equals(pr.BaseRepository?.NameWithOwner, repoFullName, StringComparison.OrdinalIgnoreCase) + ).ToList() ?? []; if (candidates.Count == 0) return null; - var mergeCommitMatches = candidates - .Where(pr => string.Equals(pr.MergeCommit?.Oid, sha, StringComparison.OrdinalIgnoreCase)) + var mergeCommitMatches = candidates.Where(pr => string.Equals(pr.MergeCommit?.Oid, sha, StringComparison.OrdinalIgnoreCase)) .OrderBy(pr => pr.Number) .ToList(); @@ -262,8 +290,10 @@ public sealed partial class GitHubCommitRangeService(ILoggerFactory logFactory, var pool = mergeCommitMatches.Count > 0 ? mergeCommitMatches : candidates; var chosen = pool.OrderBy(pr => pr.Number).First(); var numbers = string.Join(", ", candidates.Select(pr => pr.Number).Order().Select(n => $"#{n}")); - collector.EmitWarning(string.Empty, - $"Commit {sha} is associated with multiple merged pull requests ({numbers}); using #{chosen.Number}."); + collector.EmitWarning( + string.Empty, + $"Commit {sha} is associated with multiple merged pull requests ({numbers}); using #{chosen.Number}." + ); return chosen; } @@ -274,7 +304,8 @@ public sealed partial class GitHubCommitRangeService(ILoggerFactory logFactory, IDiagnosticsCollector collector, CommitRangeArguments args, IReadOnlyList shas, - Cancel ctx) + Cancel ctx + ) { var query = BuildBatchQuery(args.Owner, args.Repo, shas); var body = JsonSerializer.Serialize(new GraphQlRequest { Query = query }, CommitRangeJsonContext.Default.GraphQlRequest); @@ -282,8 +313,10 @@ public sealed partial class GitHubCommitRangeService(ILoggerFactory logFactory, using var response = await _transport.PostGraphQlAsync(body, ctx).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { - collector.EmitError(string.Empty, - $"GitHub GraphQL request for {args.Owner}/{args.Repo} failed: {(int)response.StatusCode} {response.ReasonPhrase}."); + collector.EmitError( + string.Empty, + $"GitHub GraphQL request for {args.Owner}/{args.Repo} failed: {(int)response.StatusCode} {response.ReasonPhrase}." + ); return null; } @@ -299,8 +332,10 @@ public sealed partial class GitHubCommitRangeService(ILoggerFactory logFactory, if (parsed?.Data?.Repository == null) { - collector.EmitError(string.Empty, - $"GitHub GraphQL query could not resolve repository {args.Owner}/{args.Repo}. Ensure the token can read it."); + collector.EmitError( + string.Empty, + $"GitHub GraphQL query could not resolve repository {args.Owner}/{args.Repo}. Ensure the token can read it." + ); return null; } @@ -317,10 +352,17 @@ private static string BuildBatchQuery(string owner, string repo, IReadOnlyListOptional: GitHub repository name (used when prUrl is just a number) /// Cancellation token /// PR information or null if fetch fails - public async Task FetchPrInfoAsync(string prUrl, string? owner = null, string? repo = null, CancellationToken ctx = default) + public async Task FetchPrInfoAsync( + string prUrl, + string? owner = null, + string? repo = null, + CancellationToken ctx = default + ) { try { @@ -43,7 +48,11 @@ public partial class GitHubPrService(ILoggerFactory loggerFactory, GitHubApiTran using var response = await _transport.GetAsync(url, ctx); if (!response.IsSuccessStatusCode) { - _logger.LogWarning("Failed to fetch PR info. Status: {StatusCode}, Reason: {ReasonPhrase}", response.StatusCode, response.ReasonPhrase); + _logger.LogWarning( + "Failed to fetch PR info. Status: {StatusCode}, Reason: {ReasonPhrase}", + response.StatusCode, + response.ReasonPhrase + ); return null; } @@ -87,11 +96,17 @@ public partial class GitHubPrService(ILoggerFactory loggerFactory, GitHubApiTran } } - private static (string? owner, string? repo, int? prNumber) ParsePrUrl(string prUrl, string? defaultOwner = null, string? defaultRepo = null) + private static (string? owner, string? repo, int? prNumber) ParsePrUrl( + string prUrl, + string? defaultOwner = null, + string? defaultRepo = null + ) { // Handle full URL: https://github.com/owner/repo/pull/123 - if (prUrl.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase) || - prUrl.StartsWith("http://github.com/", StringComparison.OrdinalIgnoreCase)) + if ( + prUrl.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase) || + prUrl.StartsWith("http://github.com/", StringComparison.OrdinalIgnoreCase) + ) { var uri = new Uri(prUrl); var segments = uri.Segments; @@ -120,8 +135,7 @@ private static (string? owner, string? repo, int? prNumber) ParsePrUrl(string pr } // Handle just a PR number when owner/repo are provided - if (int.TryParse(prUrl, out var prNumber) && - !string.IsNullOrWhiteSpace(defaultOwner) && !string.IsNullOrWhiteSpace(defaultRepo)) + if (int.TryParse(prUrl, out var prNumber) && !string.IsNullOrWhiteSpace(defaultOwner) && !string.IsNullOrWhiteSpace(defaultRepo)) return (defaultOwner, defaultRepo, prNumber); return (null, null, null); @@ -173,7 +187,12 @@ private static IReadOnlyList ExtractLinkedIssues(string body, string prO /// /// Fetches issue information from GitHub /// - public async Task FetchIssueInfoAsync(string issueUrl, string? owner = null, string? repo = null, CancellationToken ctx = default) + public async Task FetchIssueInfoAsync( + string issueUrl, + string? owner = null, + string? repo = null, + CancellationToken ctx = default + ) { try { @@ -190,7 +209,11 @@ private static IReadOnlyList ExtractLinkedIssues(string body, string prO using var response = await _transport.GetAsync(url, ctx); if (!response.IsSuccessStatusCode) { - _logger.LogWarning("Failed to fetch issue info. Status: {StatusCode}, Reason: {ReasonPhrase}", response.StatusCode, response.ReasonPhrase); + _logger.LogWarning( + "Failed to fetch issue info. Status: {StatusCode}, Reason: {ReasonPhrase}", + response.StatusCode, + response.ReasonPhrase + ); return null; } @@ -257,11 +280,18 @@ private static IReadOnlyList ExtractLinkedIssues(string body, string prO } /// - public async Task FetchLastFileCommitAuthorAsync(string owner, string repo, string filePath, string branch, CancellationToken ctx = default) + public async Task FetchLastFileCommitAuthorAsync( + string owner, + string repo, + string filePath, + string branch, + CancellationToken ctx = default + ) { try { - var url = $"https://api.github.com/repos/{owner}/{repo}/commits?path={Uri.EscapeDataString(filePath)}&sha={Uri.EscapeDataString(branch)}&per_page=1"; + var url = + $"https://api.github.com/repos/{owner}/{repo}/commits?path={Uri.EscapeDataString(filePath)}&sha={Uri.EscapeDataString(branch)}&per_page=1"; _logger.LogDebug("Fetching last file commit author from: {ApiUrl}", url); using var response = await _transport.GetAsync(url, ctx); @@ -285,10 +315,16 @@ private static IReadOnlyList ExtractLinkedIssues(string body, string prO } } - private static (string? owner, string? repo, int? issueNumber) ParseIssueUrl(string issueUrl, string? defaultOwner = null, string? defaultRepo = null) + private static (string? owner, string? repo, int? issueNumber) ParseIssueUrl( + string issueUrl, + string? defaultOwner = null, + string? defaultRepo = null + ) { - if (issueUrl.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase) || - issueUrl.StartsWith("http://github.com/", StringComparison.OrdinalIgnoreCase)) + if ( + issueUrl.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase) || + issueUrl.StartsWith("http://github.com/", StringComparison.OrdinalIgnoreCase) + ) { var uri = new Uri(issueUrl); var segments = uri.Segments; @@ -314,8 +350,11 @@ private static (string? owner, string? repo, int? issueNumber) ParseIssueUrl(str } } - if (int.TryParse(issueUrl, out var issueNumber) && - !string.IsNullOrWhiteSpace(defaultOwner) && !string.IsNullOrWhiteSpace(defaultRepo)) + if ( + int.TryParse(issueUrl, out var issueNumber) + && !string.IsNullOrWhiteSpace(defaultOwner) + && !string.IsNullOrWhiteSpace(defaultRepo) + ) return (defaultOwner, defaultRepo, issueNumber); return (null, null, null); @@ -347,10 +386,10 @@ private static IReadOnlyList ExtractLinkedPrs(string body, string issueO // Same-repo: #123 var sameRepoPattern = @"(?:fixed\s+by|pr|merge[sd]?|via)\s+#(\d+)"; - foreach (var prNum in Enumerable - .Select( - Enumerable.Cast(Regex.Matches(body, sameRepoPattern, RegexOptions.IgnoreCase)), - match => match.Groups[1].Value)) + foreach (var prNum in Enumerable.Select( + Enumerable.Cast(Regex.Matches(body, sameRepoPattern, RegexOptions.IgnoreCase)), + match => match.Groups[1].Value + )) { _ = prs.Add($"https://github.com/{issueOwner}/{issueRepo}/pull/{prNum}"); } @@ -421,6 +460,7 @@ private sealed class GitHubCommitListItem [JsonSerializable(typeof(List))] private sealed partial class GitHubPrJsonContext : JsonSerializerContext; - [GeneratedRegex(@"https://github\.com/([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)/pull/(\d+)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + [GeneratedRegex(@"https://github\.com/([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)/pull/(\d+)", RegexOptions.IgnoreCase | + RegexOptions.CultureInvariant)] private static partial Regex MyRegex(); } diff --git a/src/services/Elastic.Changelog/GitHub/GitHubReleaseService.cs b/src/services/Elastic.Changelog/GitHub/GitHubReleaseService.cs index d3132576fc..8d4621d440 100644 --- a/src/services/Elastic.Changelog/GitHub/GitHubReleaseService.cs +++ b/src/services/Elastic.Changelog/GitHub/GitHubReleaseService.cs @@ -18,17 +18,12 @@ public partial class GitHubReleaseService(ILoggerFactory loggerFactory, GitHubAp private readonly GitHubApiTransport _transport = transport ?? new GitHubApiTransport(); /// - public async Task FetchReleaseAsync( - string owner, - string repo, - string? version, - CancellationToken ctx = default) + public async Task FetchReleaseAsync(string owner, string repo, string? version, CancellationToken ctx = default) { try { // Build URL: /repos/{owner}/{repo}/releases/latest or /releases/tags/{version} - var isLatest = string.IsNullOrWhiteSpace(version) || - version.Equals("latest", StringComparison.OrdinalIgnoreCase); + var isLatest = string.IsNullOrWhiteSpace(version) || version.Equals("latest", StringComparison.OrdinalIgnoreCase); var url = isLatest ? $"https://api.github.com/repos/{owner}/{repo}/releases/latest" @@ -68,7 +63,8 @@ public async Task> FetchReleasesAsync( string owner, string repo, int count, - CancellationToken ctx = default) + CancellationToken ctx = default + ) { try { @@ -78,8 +74,11 @@ public async Task> FetchReleasesAsync( using var response = await _transport.GetAsync(url, ctx); if (!response.IsSuccessStatusCode) { - _logger.LogDebug("Failed to fetch releases. Status: {StatusCode}, Reason: {ReasonPhrase}", - response.StatusCode, response.ReasonPhrase); + _logger.LogDebug( + "Failed to fetch releases. Status: {StatusCode}, Reason: {ReasonPhrase}", + response.StatusCode, + response.ReasonPhrase + ); return []; } @@ -109,8 +108,12 @@ public async Task> FetchReleasesAsync( using var response = await _transport.GetAsync(asset.BrowserDownloadUrl, ctx); if (!response.IsSuccessStatusCode) { - _logger.LogDebug("Failed to download asset {AssetName}. Status: {StatusCode}, Reason: {ReasonPhrase}", - asset.Name, response.StatusCode, response.ReasonPhrase); + _logger.LogDebug( + "Failed to download asset {AssetName}. Status: {StatusCode}, Reason: {ReasonPhrase}", + asset.Name, + response.StatusCode, + response.ReasonPhrase + ); return null; } @@ -135,8 +138,11 @@ public async Task> FetchReleasesAsync( using var response = await _transport.GetAsync(url, ctx); if (!response.IsSuccessStatusCode) { - _logger.LogDebug("Failed to fetch release info. Status: {StatusCode}, Reason: {ReasonPhrase}", - response.StatusCode, response.ReasonPhrase); + _logger.LogDebug( + "Failed to fetch release info. Status: {StatusCode}, Reason: {ReasonPhrase}", + response.StatusCode, + response.ReasonPhrase + ); return null; } @@ -152,22 +158,23 @@ public async Task> FetchReleasesAsync( return ToReleaseInfo(releaseData); } - private static GitHubReleaseInfo ToReleaseInfo(GitHubReleaseResponse releaseData) => new() - { - TagName = releaseData.TagName ?? string.Empty, - Name = releaseData.Name ?? string.Empty, - Body = releaseData.Body ?? string.Empty, - Prerelease = releaseData.Prerelease, - Draft = releaseData.Draft, - HtmlUrl = releaseData.HtmlUrl ?? string.Empty, - PublishedAt = releaseData.PublishedAt, - Assets = releaseData.Assets is { Count: > 0 } - ? releaseData.Assets - .Where(a => a is { Name: not null, BrowserDownloadUrl: not null }) - .Select(a => new GitHubReleaseAsset { Name = a.Name!, BrowserDownloadUrl = a.BrowserDownloadUrl! }) - .ToArray() - : [] - }; + private static GitHubReleaseInfo ToReleaseInfo(GitHubReleaseResponse releaseData) => + new() + { + TagName = releaseData.TagName ?? string.Empty, + Name = releaseData.Name ?? string.Empty, + Body = releaseData.Body ?? string.Empty, + Prerelease = releaseData.Prerelease, + Draft = releaseData.Draft, + HtmlUrl = releaseData.HtmlUrl ?? string.Empty, + PublishedAt = releaseData.PublishedAt, + Assets = releaseData.Assets is { Count: > 0 } + ? releaseData.Assets + .Where(a => a is { Name: not null, BrowserDownloadUrl: not null }) + .Select(a => new GitHubReleaseAsset { Name = a.Name!, BrowserDownloadUrl = a.BrowserDownloadUrl! }) + .ToArray() + : [] + }; private sealed class GitHubReleaseAssetResponse { diff --git a/src/services/Elastic.Changelog/GitHub/IGitHubCommitRangeService.cs b/src/services/Elastic.Changelog/GitHub/IGitHubCommitRangeService.cs index 21912c0f09..3376c25362 100644 --- a/src/services/Elastic.Changelog/GitHub/IGitHubCommitRangeService.cs +++ b/src/services/Elastic.Changelog/GitHub/IGitHubCommitRangeService.cs @@ -69,8 +69,5 @@ public interface IGitHubCommitRangeService /// Returns null after emitting an error when the range cannot be resolved /// (unknown refs, missing credentials, API failures). /// - Task ResolvePullRequestsAsync( - IDiagnosticsCollector collector, - CommitRangeArguments args, - Cancel ctx); + Task ResolvePullRequestsAsync(IDiagnosticsCollector collector, CommitRangeArguments args, Cancel ctx); } diff --git a/src/services/Elastic.Changelog/GitHub/IGitHubPrService.cs b/src/services/Elastic.Changelog/GitHub/IGitHubPrService.cs index 6ccc295aba..6aa834f0a3 100644 --- a/src/services/Elastic.Changelog/GitHub/IGitHubPrService.cs +++ b/src/services/Elastic.Changelog/GitHub/IGitHubPrService.cs @@ -33,5 +33,11 @@ public interface IGitHubPrService Task FetchCommitAuthorAsync(string owner, string repo, string sha, CancellationToken ctx = default); /// Returns the author login of the last commit that touched a file (for manual-edit detection in CI). - Task FetchLastFileCommitAuthorAsync(string owner, string repo, string filePath, string branch, CancellationToken ctx = default); + Task FetchLastFileCommitAuthorAsync( + string owner, + string repo, + string filePath, + string branch, + CancellationToken ctx = default + ); } diff --git a/src/services/Elastic.Changelog/GitHub/IGitHubReleaseService.cs b/src/services/Elastic.Changelog/GitHub/IGitHubReleaseService.cs index fdb507ff6f..7ec184ab6d 100644 --- a/src/services/Elastic.Changelog/GitHub/IGitHubReleaseService.cs +++ b/src/services/Elastic.Changelog/GitHub/IGitHubReleaseService.cs @@ -79,11 +79,7 @@ public interface IGitHubReleaseService /// Version tag or "latest" (null defaults to latest) /// Cancellation token /// Release information or null if fetch fails - Task FetchReleaseAsync( - string owner, - string repo, - string? version, - CancellationToken ctx = default); + Task FetchReleaseAsync(string owner, string repo, string? version, CancellationToken ctx = default); /// /// Fetches the most recent releases from GitHub, newest first @@ -93,11 +89,7 @@ public interface IGitHubReleaseService /// Maximum number of releases to fetch /// Cancellation token /// The releases, or an empty list if the fetch fails - Task> FetchReleasesAsync( - string owner, - string repo, - int count, - CancellationToken ctx = default); + Task> FetchReleasesAsync(string owner, string repo, int count, CancellationToken ctx = default); /// /// Downloads a release asset's content as text diff --git a/src/services/Elastic.Changelog/GitHub/ReleaseNoteParser.cs b/src/services/Elastic.Changelog/GitHub/ReleaseNoteParser.cs index 21fafb452f..7b9f87d0d1 100644 --- a/src/services/Elastic.Changelog/GitHub/ReleaseNoteParser.cs +++ b/src/services/Elastic.Changelog/GitHub/ReleaseNoteParser.cs @@ -65,7 +65,8 @@ public static partial class ReleaseNoteParser { // Regex for PR line, with either bullet char: "* Title by @author in #123", // "- Title by @author in #123", or "... in https://github.com/owner/repo/pull/123". - [GeneratedRegex(@"^[*-]\s+(.+?)\s+by\s+@([\w-]+)\s+in\s+(?:#(\d+)|https://github\.com/[^/]+/[^/]+/pull/(\d+))", RegexOptions.Multiline | RegexOptions.IgnoreCase)] + [GeneratedRegex(@"^[*-]\s+(.+?)\s+by\s+@([\w-]+)\s+in\s+(?:#(\d+)|https://github\.com/[^/]+/[^/]+/pull/(\d+))", RegexOptions.Multiline | + RegexOptions.IgnoreCase)] private static partial Regex PrLineRegex(); // Regex for section headers at any level >= 2: "## 🐛 Bug Fixes" or "### ✨ Features". @@ -80,15 +81,25 @@ public static partial class ReleaseNoteParser private static readonly string[] ReleaseDrafterEmojis = [ "\uD83D\uDCA5", // Breaking Changes - "\u2728", // Features + + "\u2728", // Features + "\uD83D\uDC1B", // Bug Fixes + "\uD83D\uDCDD", // Documentation + "\uD83E\uDDF0", // Maintenance + "\u2699\uFE0F", // Configuration + "\uD83C\uDFA8", // Redesign + "\uD83D\uDD12", // Security + "\u26A0\uFE0F", // Deprecation - "\uD83D\uDE80" // Release + + "\uD83D\uDE80" // Release + ]; // Mapping from section header keywords to changelog types @@ -127,11 +138,7 @@ public static ParsedReleaseNotes Parse(string body) { if (string.IsNullOrWhiteSpace(body)) { - return new ParsedReleaseNotes - { - Format = ReleaseNoteFormat.Unknown, - PrReferences = [] - }; + return new ParsedReleaseNotes { Format = ReleaseNoteFormat.Unknown, PrReferences = [] }; } var format = DetectFormat(body); @@ -144,12 +151,7 @@ public static ParsedReleaseNotes Parse(string body) _ => ParseUnknownFormat(body) }; - return new ParsedReleaseNotes - { - Format = format, - PrReferences = prReferences, - FullChangelogUrl = fullChangelogUrl - }; + return new ParsedReleaseNotes { Format = format, PrReferences = prReferences, FullChangelogUrl = fullChangelogUrl }; } /// @@ -169,8 +171,7 @@ public static ReleaseNoteFormat DetectFormat(string body) return ReleaseNoteFormat.Unknown; } - private static bool HasEmojiSectionHeaders(string body) => - body.Contains("##") && ReleaseDrafterEmojis.Any(body.Contains); + private static bool HasEmojiSectionHeaders(string body) => body.Contains("##") && ReleaseDrafterEmojis.Any(body.Contains); private static string? ExtractFullChangelogUrl(string body) { @@ -242,7 +243,8 @@ private static List ParseGitHubDefaultFormat(string body) Title = title, Author = author, SectionTitle = null, - InferredType = null // No type inference in GitHub default format + InferredType = + null // No type inference in GitHub default format }); } diff --git a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs index 99cb0750ba..cff6af3370 100644 --- a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs +++ b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs @@ -96,7 +96,8 @@ public class GitHubReleaseChangelogService( private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem); private readonly IGitHubReleaseService _releaseService = releaseService ?? new GitHubReleaseService(logFactory); private readonly IGitHubPrService _prService = prService ?? new GitHubPrService(logFactory); - private readonly ChangelogBundlingService _bundlingService = bundlingService ?? new ChangelogBundlingService(logFactory, fileSystem, configurationContext); + private readonly ChangelogBundlingService _bundlingService = bundlingService ?? + new ChangelogBundlingService(logFactory, fileSystem, configurationContext); public async Task CreateChangelogsFromRelease( IDiagnosticsCollector collector, @@ -121,9 +122,11 @@ Cancel ctx var product = configurationContext.ProductsConfiguration.GetProductByRepositoryName(repo); if (product == null) { - collector.EmitError(string.Empty, + collector.EmitError( + string.Empty, $"Could not find product for repository '{repo}' in products.yml. " + - "Ensure the repository name matches a product ID or a product has 'repository: {repo}' configured."); + "Ensure the repository name matches a product ID or a product has 'repository: {repo}' configured." + ); return false; } @@ -144,9 +147,11 @@ Cancel ctx var release = await _releaseService.FetchReleaseAsync(owner, repo, input.Version, ctx); if (release == null) { - collector.EmitError(string.Empty, + collector.EmitError( + string.Empty, $"Failed to fetch release for {owner}/{repo}@{input.Version}. " + - "Ensure the repository exists and the version tag is valid."); + "Ensure the repository exists and the version tag is valid." + ); return false; } @@ -154,8 +159,11 @@ Cancel ctx // 5. Parse release notes var parsedNotes = ReleaseNoteParser.Parse(release.Body); - _logger.LogInformation("Detected format: {Format}, found {Count} PR references", - parsedNotes.Format, parsedNotes.PrReferences.Count); + _logger.LogInformation( + "Detected format: {Format}, found {Count} PR references", + parsedNotes.Format, + parsedNotes.PrReferences.Count + ); if (parsedNotes.PrReferences.Count == 0) { @@ -170,12 +178,7 @@ Cancel ctx _logger.LogInformation("Inferred lifecycle: {Lifecycle}, target version: {Target}", lifecycle, targetVersion); // Create product filter with inferred values - var productInfo = new ProductArgument - { - Product = product.Id, - Target = targetVersion, - Lifecycle = lifecycle - }; + var productInfo = new ProductArgument { Product = product.Id, Target = targetVersion, Lifecycle = lifecycle }; // 7. Process each PR and create changelog files var outputDir = input.Output ?? _fileSystem.Path.Join(_fileSystem.Directory.GetCurrentDirectory(), "changelogs"); @@ -187,9 +190,21 @@ Cancel ctx foreach (var prRef in parsedNotes.PrReferences) { - var success = await ProcessPrReference( - collector, config, owner, repo, prRef, - productInfo, stripTitlePrefix, parsedNotes.Format, outputDir, createdFiles, input.WarnOnTypeMismatch, ctx); + var success = + await ProcessPrReference( + collector, + config, + owner, + repo, + prRef, + productInfo, + stripTitlePrefix, + parsedNotes.Format, + outputDir, + createdFiles, + input.WarnOnTypeMismatch, + ctx + ); if (success) successCount++; } @@ -199,7 +214,8 @@ Cancel ctx // 8. Optionally create bundle file if changelogs were created if (input.CreateBundle && createdFiles.Count > 0) { - var bundlePath = await CreateBundleViaService(collector, outputDir, createdFiles, productInfo, owner, repo, input, release, ctx); + var bundlePath = + await CreateBundleViaService(collector, outputDir, createdFiles, productInfo, owner, repo, input, release, ctx); if (bundlePath != null) _logger.LogInformation("Created bundle file: {BundlePath}", bundlePath); } @@ -230,7 +246,8 @@ private async Task ProcessPrReference( string outputDir, List createdFiles, bool warnOnTypeMismatch, - Cancel ctx) + Cancel ctx + ) { var prUrl = $"https://github.com/{owner}/{repo}/pull/{prRef.PrNumber}"; @@ -260,21 +277,28 @@ private async Task ProcessPrReference( var finalTypeString = labelDerivedType ?? prRef.InferredType ?? ChangelogEntryType.Other.ToStringFast(true); // Parse to enum - var finalType = ChangelogEntryTypeExtensions.TryParse(finalTypeString, out var parsed, ignoreCase: true, allowMatchingMetadataAttribute: true) - ? parsed - : ChangelogEntryType.Other; + var finalType = ChangelogEntryTypeExtensions.TryParse( + finalTypeString, + out var parsed, + ignoreCase: true, + allowMatchingMetadataAttribute: true + ) ? parsed : ChangelogEntryType.Other; // Warn on type mismatch if Release Drafter format and warning enabled - if (format == ReleaseNoteFormat.ReleaseDrafter && - warnOnTypeMismatch && - labelDerivedType != null && - prRef.InferredType != null && - !string.Equals(labelDerivedType, prRef.InferredType, StringComparison.OrdinalIgnoreCase)) + if ( + format == ReleaseNoteFormat.ReleaseDrafter + && warnOnTypeMismatch + && labelDerivedType != null + && prRef.InferredType != null + && !string.Equals(labelDerivedType, prRef.InferredType, StringComparison.OrdinalIgnoreCase) + ) { - collector.EmitWarning(prUrl, + collector.EmitWarning( + prUrl, $"Type mismatch for PR #{prRef.PrNumber}: " + - $"section header suggests '{prRef.InferredType}' but labels suggest '{labelDerivedType}'. " + - "Using label-derived type."); + $"section header suggests '{prRef.InferredType}' but labels suggest '{labelDerivedType}'. " + + "Using label-derived type." + ); } // Build title @@ -287,14 +311,22 @@ private async Task ProcessPrReference( { Title = title, Type = finalType, - Products = [new ProductReference - { - ProductId = productInfo.Product ?? "", - Target = productInfo.Target, - Lifecycle = !string.IsNullOrWhiteSpace(productInfo.Lifecycle) - ? (LifecycleExtensions.TryParse(productInfo.Lifecycle, out var lc, ignoreCase: true, allowMatchingMetadataAttribute: true) ? lc : null) - : null - }], + Products = + [ + new ProductReference + { + ProductId = productInfo.Product ?? "", + Target = productInfo.Target, + Lifecycle = !string.IsNullOrWhiteSpace(productInfo.Lifecycle) + ? (LifecycleExtensions.TryParse( + productInfo.Lifecycle, + out var lc, + ignoreCase: true, + allowMatchingMetadataAttribute: true + ) ? lc : null) + : null + } + ], Areas = labelDerivedAreas, Prs = [prUrl] }; @@ -316,8 +348,7 @@ private async Task ProcessPrReference( return true; } - private static string GenerateYaml(ChangelogEntry data) => - ReleaseNotesSerialization.SerializeEntry(data); + private static string GenerateYaml(ChangelogEntry data) => ReleaseNotesSerialization.SerializeEntry(data); private async Task CreateBundleViaService( IDiagnosticsCollector collector, @@ -328,7 +359,8 @@ private static string GenerateYaml(ChangelogEntry data) => string repo, CreateChangelogsFromReleaseArguments input, GitHubReleaseInfo release, - Cancel ctx) + Cancel ctx + ) { // Build the bundles subfolder path (mirrors the previous CreateBundleFile convention) var bundlesDir = _fileSystem.Path.Join(outputDir, "bundles"); @@ -340,13 +372,11 @@ private static string GenerateYaml(ChangelogEntry data) => var bundlePath = _fileSystem.Path.Join(bundlesDir, bundleFilename); // Build PR URL list from created file names — gh-release names files as --.yaml - var prUrls = createdFileNames - .Select(filename => - { - var prNumber = filename.Split('-')[0]; - return $"https://github.com/{owner}/{repo}/pull/{prNumber}"; - }) - .ToArray(); + var prUrls = createdFileNames.Select(filename => + { + var prNumber = filename.Split('-')[0]; + return $"https://github.com/{owner}/{repo}/pull/{prNumber}"; + }).ToArray(); // Use explicit release date if provided, otherwise GitHub release published date, otherwise fall back to auto-population var releaseDate = input.ReleaseDate; @@ -373,9 +403,9 @@ private static string GenerateYaml(ChangelogEntry data) => } private static string? MapLabelsToType(string[] labels, IReadOnlyDictionary labelToTypeMapping) => - labels - .Select(label => labelToTypeMapping.TryGetValue(label, out var mappedType) ? mappedType : null) - .FirstOrDefault(mappedType => mappedType != null); + labels.Select(label => labelToTypeMapping.TryGetValue(label, out var mappedType) ? mappedType : null).FirstOrDefault( + mappedType => mappedType != null + ); private static List MapLabelsToAreas(string[] labels, IReadOnlyDictionary> labelToAreasMapping) { @@ -396,7 +426,8 @@ private bool ShouldSkipPrDueToLabelBlockers( ProductArgument productInfo, ChangelogConfiguration config, IDiagnosticsCollector collector, - string prUrl) + string prUrl + ) { var createRules = config.Rules?.Create; if (createRules == null) @@ -417,7 +448,8 @@ private static bool ShouldSkipByCreateRules( CreateRules rules, IDiagnosticsCollector collector, string prUrl, - string? productContext) + string? productContext + ) { if (rules.Labels == null || rules.Labels.Count == 0) return false; @@ -429,11 +461,15 @@ private static bool ShouldSkipByCreateRules( if (mode == FieldMode.Exclude) { - var matchingLabel = rules.Labels.FirstOrDefault(blockerLabel => prLabels.Contains(blockerLabel, StringComparer.OrdinalIgnoreCase)); + var matchingLabel = rules.Labels.FirstOrDefault( + blockerLabel => prLabels.Contains(blockerLabel, StringComparer.OrdinalIgnoreCase) + ); if (matchingLabel != null) { - collector.EmitWarning(prUrl, - $"{prefix} Skipping changelog creation for PR {prUrl} due to blocking label '{matchingLabel}'{productSuffix} (match: {match.ToString().ToLowerInvariant()})."); + collector.EmitWarning( + prUrl, + $"{prefix} Skipping changelog creation for PR {prUrl} due to blocking label '{matchingLabel}'{productSuffix} (match: {match.ToString().ToLowerInvariant()})." + ); return true; } } @@ -443,8 +479,10 @@ private static bool ShouldSkipByCreateRules( if (!hasMatch) { var labelsList = string.Join(", ", rules.Labels); - collector.EmitWarning(prUrl, - $"{prefix} Skipping changelog creation for PR {prUrl}, no labels match rules.create.include [{labelsList}]{productSuffix} (match: {match.ToString().ToLowerInvariant()})."); + collector.EmitWarning( + prUrl, + $"{prefix} Skipping changelog creation for PR {prUrl}, no labels match rules.create.include [{labelsList}]{productSuffix} (match: {match.ToString().ToLowerInvariant()})." + ); return true; } } diff --git a/src/services/Elastic.Changelog/Migration/MigrateFromWebScope.cs b/src/services/Elastic.Changelog/Migration/MigrateFromWebScope.cs index 6919b552e2..60359a698e 100644 --- a/src/services/Elastic.Changelog/Migration/MigrateFromWebScope.cs +++ b/src/services/Elastic.Changelog/Migration/MigrateFromWebScope.cs @@ -77,8 +77,10 @@ public sealed record MigrateFromWebScope if (unknown.Count > 0) { var known = string.Join(", ", All.Select(s => s.ProductId).Order(StringComparer.Ordinal)); - collector.EmitError(string.Empty, - $"Unknown product id(s) in --products: {string.Join(", ", unknown)}. Products in the checked-in migration scope: {known}. Add an entry to MigrateFromWebScope.All before running the migration."); + collector.EmitError( + string.Empty, + $"Unknown product id(s) in --products: {string.Join(", ", unknown)}. Products in the checked-in migration scope: {known}. Add an entry to MigrateFromWebScope.All before running the migration." + ); return null; } diff --git a/src/services/Elastic.Changelog/Migration/ReleaseNotesPageParser.cs b/src/services/Elastic.Changelog/Migration/ReleaseNotesPageParser.cs index 41d4896743..6f03036853 100644 --- a/src/services/Elastic.Changelog/Migration/ReleaseNotesPageParser.cs +++ b/src/services/Elastic.Changelog/Migration/ReleaseNotesPageParser.cs @@ -52,7 +52,8 @@ public static IReadOnlyList Parse( IDiagnosticsCollector collector, string markdown, string sourceId, - MigrateFromWebScope scope) + MigrateFromWebScope scope + ) { var lines = markdown.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n'); var releases = new List(); @@ -143,7 +144,10 @@ public void ConsumeLine(IDiagnosticsCollector collector, string line) } if (dateMatch.Success) - collector.EmitWarning(sourceId, $"Could not parse release date '{dateMatch.Groups["date"].Value}' for {version}; keeping the line as description text."); + collector.EmitWarning( + sourceId, + $"Could not parse release date '{dateMatch.Groups["date"].Value}' for {version}; keeping the line as description text." + ); ConsumeContentLine(line); } @@ -155,7 +159,10 @@ private void ConsumeSubsectionHeading(IDiagnosticsCollector collector, string li { // Unrecognized subsections flow into the description verbatim (heading included) so the // published content is preserved even when it cannot be mapped to typed entries. - collector.EmitWarning(sourceId, $"Unrecognized subsection '### {title.Trim()}' under {version}; preserving it in the bundle description."); + collector.EmitWarning( + sourceId, + $"Unrecognized subsection '### {title.Trim()}' under {version}; preserving it in the bundle description." + ); _entryType = null; _collectingEntries = false; AppendDescriptionLine(line); @@ -236,17 +243,16 @@ public MigratedRelease Build() } } - private static ChangelogEntryType? ResolveSectionType(string heading) => - heading.Trim().ToLowerInvariant() switch - { - "features and enhancements" or "features" or "enhancements" => ChangelogEntryType.Enhancement, - "fixes" or "bug fixes" => ChangelogEntryType.BugFix, - "breaking changes" => ChangelogEntryType.BreakingChange, - "deprecations" => ChangelogEntryType.Deprecation, - "known issues" => ChangelogEntryType.KnownIssue, - "security" or "security updates" => ChangelogEntryType.Security, - _ => null - }; + private static ChangelogEntryType? ResolveSectionType(string heading) => heading.Trim().ToLowerInvariant() switch + { + "features and enhancements" or "features" or "enhancements" => ChangelogEntryType.Enhancement, + "fixes" or "bug fixes" => ChangelogEntryType.BugFix, + "breaking changes" => ChangelogEntryType.BreakingChange, + "deprecations" => ChangelogEntryType.Deprecation, + "known issues" => ChangelogEntryType.KnownIssue, + "security" or "security updates" => ChangelogEntryType.Security, + _ => null + }; private static bool TryParseReleaseDate(string text, out DateOnly date) { @@ -282,17 +288,24 @@ private static (string Title, List Prs) ExtractPrReferences(string text, { var prs = new List(); - var title = PrLinkRegex().Replace(text, m => - { - prs.Add(m.Groups["url"].Value); - return string.Empty; - }); + var title = PrLinkRegex().Replace( + text, + m => + { + prs.Add(m.Groups["url"].Value); + return string.Empty; + } + ); - title = BarePrRefRegex().Replace(title, m => - { - prs.Add($"https://github.com/{scope.Owner}/{scope.Repo}/pull/{m.Groups["number"].Value}"); - return string.Empty; - }); + title = + BarePrRefRegex().Replace( + title, + m => + { + prs.Add($"https://github.com/{scope.Owner}/{scope.Repo}/pull/{m.Groups["number"].Value}"); + return string.Empty; + } + ); return (NormalizeTitle(title), prs); } diff --git a/src/services/Elastic.Changelog/Migration/WebMigrationService.cs b/src/services/Elastic.Changelog/Migration/WebMigrationService.cs index 5f25cdf725..5caa870698 100644 --- a/src/services/Elastic.Changelog/Migration/WebMigrationService.cs +++ b/src/services/Elastic.Changelog/Migration/WebMigrationService.cs @@ -107,7 +107,12 @@ public async Task MigrateFromWeb(IDiagnosticsCollector collector, MigrateF /// emitted) when the product fails before the upload phase — the caller continues with the /// remaining products and fails the run at the end. /// - private async Task?> MigrateProduct(IDiagnosticsCollector collector, MigrateFromWebArguments args, MigrateFromWebScope scope, Cancel ctx) + private async Task?> MigrateProduct( + IDiagnosticsCollector collector, + MigrateFromWebArguments args, + MigrateFromWebScope scope, + Cancel ctx + ) { var sourceUrl = $"https://raw.githubusercontent.com/{scope.Owner}/{scope.Repo}/{scope.Ref}/{scope.Path}"; var markdown = await FetchMarkdown(collector, sourceUrl, ctx); @@ -117,7 +122,10 @@ public async Task MigrateFromWeb(IDiagnosticsCollector collector, MigrateF var releases = ReleaseNotesPageParser.Parse(collector, markdown, sourceUrl, scope); if (releases.Count == 0) { - collector.EmitError(sourceUrl, $"No release sections were parsed from the published release notes for '{scope.ProductId}'; refusing to continue with an empty scope."); + collector.EmitError( + sourceUrl, + $"No release sections were parsed from the published release notes for '{scope.ProductId}'; refusing to continue with an empty scope." + ); return null; } @@ -146,7 +154,10 @@ public async Task MigrateFromWeb(IDiagnosticsCollector collector, MigrateF using var response = await client.GetAsync(sourceUrl, ctx); if (!response.IsSuccessStatusCode) { - collector.EmitError(sourceUrl, $"Fetching published release notes failed with HTTP {(int)response.StatusCode} ({response.StatusCode})."); + collector.EmitError( + sourceUrl, + $"Fetching published release notes failed with HTTP {(int)response.StatusCode} ({response.StatusCode})." + ); return null; } @@ -167,7 +178,8 @@ public async Task MigrateFromWeb(IDiagnosticsCollector collector, MigrateF private static (List InScope, List Results) ApplyScopeFilters( IReadOnlyList releases, MigrateFromWebScope scope, - IReadOnlyList versions) + IReadOnlyList versions + ) { var cutoff = VersionOrDate.Parse(scope.Cutoff); var selection = versions.Count > 0 ? new HashSet(versions, StringComparer.OrdinalIgnoreCase) : null; @@ -202,7 +214,11 @@ private sealed record StagedBundle(string Key, string LocalPath, string LocalETa /// the local single-part ETag used to distinguish identical from divergent remote content. /// [SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms", Justification = "MD5 matches the S3 single-part ETag, used for content comparison only")] - private List StageBundles(IDiagnosticsCollector collector, MigrateFromWebScope scope, IReadOnlyList releases) + private List StageBundles( + IDiagnosticsCollector collector, + MigrateFromWebScope scope, + IReadOnlyList releases + ) { var stagingDir = _fileSystem.Path.Join(_fileSystem.Path.GetTempPath(), "docs-builder-migrate-from-web", scope.ProductId); var staged = new List(releases.Count); @@ -232,12 +248,17 @@ private List StageBundles(IDiagnosticsCollector collector, Migrate private async Task> UploadCreateOnly( MigrateFromWebArguments args, IReadOnlyList staged, - Cancel ctx) + Cancel ctx + ) { // A credential-free dry run: without a bucket there is nothing to compare against, so every // in-scope key is reported as would-create. if (args.DryRun && string.IsNullOrWhiteSpace(args.S3BucketName)) - return [.. staged.Select(s => new MigrationKeyResult(s.Key, OutcomeWouldCreate, s.LocalETag, "no bucket specified; existence not checked"))]; + return [ + .. staged.Select( + s => new MigrationKeyResult(s.Key, OutcomeWouldCreate, s.LocalETag, "no bucket specified; existence not checked") + ) + ]; using var defaultClient = s3Client is null ? new AmazonS3Client() : null; var client = s3Client ?? defaultClient!; @@ -293,11 +314,7 @@ private async Task MigrateKey(IAmazonS3 client, MigrateFromW { try { - var response = await client.GetObjectMetadataAsync(new GetObjectMetadataRequest - { - BucketName = bucketName, - Key = key - }, ctx); + var response = await client.GetObjectMetadataAsync(new GetObjectMetadataRequest { BucketName = bucketName, Key = key }, ctx); return response.ETag.Trim('"'); } catch (AmazonS3Exception ex) when (ex.StatusCode == HttpStatusCode.NotFound) @@ -330,18 +347,23 @@ public static string FormatReport(MigrateFromWebScope scope, MigrateFromWebArgum _ = sb.AppendLine(CultureInfo.InvariantCulture, $"- product: `{scope.ProductId}`"); _ = sb.AppendLine(CultureInfo.InvariantCulture, $"- source: `{scope.Owner}/{scope.Repo}@{scope.Ref}` `{scope.Path}`"); _ = sb.AppendLine(CultureInfo.InvariantCulture, $"- cutoff: `{scope.Cutoff}`"); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $"- mode: {(args.DryRun ? "dry-run (no S3 writes)" : $"upload to `{args.S3BucketName}`")}"); + _ = + sb.AppendLine( + CultureInfo.InvariantCulture, + $"- mode: {(args.DryRun ? "dry-run (no S3 writes)" : $"upload to `{args.S3BucketName}`")}" + ); _ = sb.AppendLine(); _ = sb.AppendLine("| key | outcome | etag | detail |"); _ = sb.AppendLine("|---|---|---|---|"); foreach (var result in results.OrderBy(r => r.Key, StringComparer.Ordinal)) - _ = sb.AppendLine(CultureInfo.InvariantCulture, $"| `{result.Key}` | {result.Outcome} | {(result.ETag is null ? "" : $"`{result.ETag}`")} | {result.Detail} |"); + _ = + sb.AppendLine( + CultureInfo.InvariantCulture, + $"| `{result.Key}` | {result.Outcome} | {(result.ETag is null ? "" : $"`{result.ETag}`")} | {result.Detail} |" + ); - var counts = results - .GroupBy(r => r.Outcome) - .OrderBy(g => g.Key, StringComparer.Ordinal) - .Select(g => $"{g.Key} {g.Count()}"); + var counts = results.GroupBy(r => r.Outcome).OrderBy(g => g.Key, StringComparer.Ordinal).Select(g => $"{g.Key} {g.Count()}"); _ = sb.AppendLine(); _ = sb.AppendLine(CultureInfo.InvariantCulture, $"totals: {string.Join(", ", counts)}"); return sb.ToString(); diff --git a/src/services/Elastic.Changelog/ProductArgument.cs b/src/services/Elastic.Changelog/ProductArgument.cs index c1ac97cf9f..e80b862c32 100644 --- a/src/services/Elastic.Changelog/ProductArgument.cs +++ b/src/services/Elastic.Changelog/ProductArgument.cs @@ -25,22 +25,13 @@ public record ProductArgument /// /// Converts this ProductArgument to a ProductReference domain type. /// - public ProductReference ToProductReference() => new() - { - ProductId = Product ?? "", - Target = Target, - Lifecycle = ParseLifecycle(Lifecycle) - }; + public ProductReference ToProductReference() => + new() { ProductId = Product ?? "", Target = Target, Lifecycle = ParseLifecycle(Lifecycle) }; /// /// Converts this ProductArgument to a BundledProduct domain type. /// - public BundledProduct ToBundledProduct() => new() - { - ProductId = Product ?? "", - Target = Target, - Lifecycle = ParseLifecycle(Lifecycle) - }; + public BundledProduct ToBundledProduct() => new() { ProductId = Product ?? "", Target = Target, Lifecycle = ParseLifecycle(Lifecycle) }; /// /// Formats a product spec string matching the CLI format: "product [target] [lifecycle]". @@ -98,8 +89,6 @@ public static IReadOnlyList ParseProductSpecs(string? specs) if (string.IsNullOrEmpty(value)) return null; - return LifecycleExtensions.TryParse(value, out var result, ignoreCase: true, allowMatchingMetadataAttribute: true) - ? result - : null; + return LifecycleExtensions.TryParse(value, out var result, ignoreCase: true, allowMatchingMetadataAttribute: true) ? result : null; } } diff --git a/src/services/Elastic.Changelog/Reconciliation/BundleRegistryReconciler.cs b/src/services/Elastic.Changelog/Reconciliation/BundleRegistryReconciler.cs index 526854f54f..a8dba11196 100644 --- a/src/services/Elastic.Changelog/Reconciliation/BundleRegistryReconciler.cs +++ b/src/services/Elastic.Changelog/Reconciliation/BundleRegistryReconciler.cs @@ -117,7 +117,10 @@ public async Task ReconcileGroupAsync(ChangelogScope scop { _logger.LogWarning( "Public manifest {Key} declares schema_version {Found} > supported {Supported}; leaving it untouched", - scope.RegistryKey, newer.SchemaVersion, Registry.CurrentSchemaVersion); + scope.RegistryKey, + newer.SchemaVersion, + Registry.CurrentSchemaVersion + ); return GroupReconcileOutcome.RefusedNewerSchema; } @@ -140,13 +143,7 @@ public async Task ReconcileGroupAsync(ChangelogScope scop return GroupReconcileOutcome.Unchanged; } - var manifest = new Registry - { - Product = scope.Group, - Producer = Producer, - GeneratedAt = _time.GetUtcNow(), - Bundles = entries - }; + var manifest = new Registry { Product = scope.Group, Producer = Producer, GeneratedAt = _time.GetUtcNow(), Bundles = entries }; var json = JsonSerializer.Serialize(manifest, RegistryJsonContext.Default.Registry); if (await TryPutManifest(scope, json, existing.ETag, attempt, ctx)) @@ -154,14 +151,19 @@ public async Task ReconcileGroupAsync(ChangelogScope scop _metrics.IncrementRegistryWrites(); _logger.LogInformation( "Wrote public manifest {Key} with {Count} entrie(s) ({Reused} reused, {Recomputed} recomputed)", - scope.RegistryKey, entries.Count, reused, entries.Count - reused); + scope.RegistryKey, + entries.Count, + reused, + entries.Count - reused + ); return GroupReconcileOutcome.Written; } await BackOff(attempt, ctx); } throw new ReconcileConflictException( - $"Public manifest {scope.RegistryKey} kept changing concurrently after {MaxWriteAttempts} attempts; failing the message for redelivery."); + $"Public manifest {scope.RegistryKey} kept changing concurrently after {MaxWriteAttempts} attempts; failing the message for redelivery." + ); } /// @@ -171,12 +173,7 @@ public async Task ReconcileGroupAsync(ChangelogScope scop /// private async Task> ListGroupFiles(ChangelogScope scope, Cancel ctx) { - var request = new ListObjectsV2Request - { - BucketName = publicBucketName, - Prefix = scope.Prefix, - Delimiter = "/" - }; + var request = new ListObjectsV2Request { BucketName = publicBucketName, Prefix = scope.Prefix, Delimiter = "/" }; var files = new List(); ListObjectsV2Response response; @@ -192,14 +189,15 @@ private async Task> ListGroupFiles(ChangelogScope scope, _metrics.IncrementObjectsListed(); } request.ContinuationToken = response.NextContinuationToken; - } while (response.IsTruncated == true); + } + while (response.IsTruncated == true); return files; } private static bool IsYamlFileName(string file) => - file.Length > 0 - && (file.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) || file.EndsWith(".yml", StringComparison.OrdinalIgnoreCase)); + file.Length > 0 && + (file.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) || file.EndsWith(".yml", StringComparison.OrdinalIgnoreCase)); private sealed record ManifestState(Registry? Manifest, string? ETag, bool Exists, bool Corrupt); @@ -209,11 +207,7 @@ private async Task FetchManifest(string key, Cancel ctx) string? etag = null; try { - using var response = await s3Client.GetObjectAsync(new GetObjectRequest - { - BucketName = publicBucketName, - Key = key - }, ctx); + using var response = await s3Client.GetObjectAsync(new GetObjectRequest { BucketName = publicBucketName, Key = key }, ctx); etag = response.ETag; await using var stream = response.ResponseStream; @@ -238,7 +232,8 @@ private async Task FetchManifest(string key, Cancel ctx) ChangelogScope scope, IReadOnlyList listing, IReadOnlyList reusable, - Cancel ctx) + Cancel ctx + ) { var byFile = reusable.ToDictionary(b => b.File, b => b, StringComparer.Ordinal); var built = new RegistryBundle?[listing.Count]; @@ -255,9 +250,11 @@ await Parallel.ForEachAsync( // Amends are never ETag-skipped: their target depends on the parent bundle too, // and a parent appearing or changing does not touch the amend's own ETag. - if (!BundleAmendMerger.IsAmendFile(file) + if ( + !BundleAmendMerger.IsAmendFile(file) && byFile.TryGetValue(file, out var previous) - && string.Equals(previous.ETag, etag, StringComparison.Ordinal)) + && string.Equals(previous.ETag, etag, StringComparison.Ordinal) + ) { built[i] = previous; _ = Interlocked.Increment(ref reused); @@ -267,7 +264,8 @@ await Parallel.ForEachAsync( var target = await ComputeTarget(scope, file, ct); _metrics.IncrementEntriesRecomputed(); built[i] = new RegistryBundle { File = file, Target = target, ETag = etag }; - }); + } + ); // A null slot means the object vanished between the listing and the read; the delete's own // event (or the next reconcile) covers it. @@ -298,7 +296,10 @@ await Parallel.ForEachAsync( { _logger.LogWarning( "Amend {Prefix}{File} has no parent bundle {Parent} in the public bucket yet; recording a null target", - scope.Prefix, file, parentFile); + scope.Prefix, + file, + parentFile + ); return null; } @@ -309,11 +310,8 @@ await Parallel.ForEachAsync( { try { - using var response = await s3Client.GetObjectAsync(new GetObjectRequest - { - BucketName = publicBucketName, - Key = scope.Prefix + file - }, ctx); + using var response = + await s3Client.GetObjectAsync(new GetObjectRequest { BucketName = publicBucketName, Key = scope.Prefix + file }, ctx); await using var stream = response.ResponseStream; using var reader = new StreamReader(stream); @@ -341,12 +339,11 @@ private async Task TryDeleteManifest(ChangelogScope scope, string etag, in { try { - _ = await s3Client.DeleteObjectAsync(new DeleteObjectRequest - { - BucketName = publicBucketName, - Key = scope.RegistryKey, - IfMatch = etag - }, ctx); + _ = + await s3Client.DeleteObjectAsync( + new DeleteObjectRequest { BucketName = publicBucketName, Key = scope.RegistryKey, IfMatch = etag }, + ctx + ); _metrics.IncrementRegistryDeletes(); _logger.LogInformation("Deleted public manifest {Key}: the group is empty", scope.RegistryKey); return true; @@ -361,7 +358,10 @@ private async Task TryDeleteManifest(ChangelogScope scope, string etag, in _metrics.IncrementWriteConflicts(); _logger.LogInformation( "Public manifest {Key} changed concurrently during delete (attempt {Attempt}/{Max}); re-listing and retrying", - scope.RegistryKey, attempt, MaxWriteAttempts); + scope.RegistryKey, + attempt, + MaxWriteAttempts + ); return false; } } @@ -392,7 +392,10 @@ private async Task TryPutManifest(ChangelogScope scope, string json, strin _metrics.IncrementWriteConflicts(); _logger.LogInformation( "Public manifest {Key} changed concurrently (attempt {Attempt}/{Max}); re-listing and retrying", - scope.RegistryKey, attempt, MaxWriteAttempts); + scope.RegistryKey, + attempt, + MaxWriteAttempts + ); return false; } } @@ -411,9 +414,7 @@ private async Task BackOff(int attempt, Cancel ctx) } private static List Sort(IEnumerable entries) => - [.. entries - .OrderByDescending(b => VersionOrDate.Parse(b.Target ?? string.Empty)) - .ThenBy(b => b.File, StringComparer.Ordinal)]; + [.. entries.OrderByDescending(b => VersionOrDate.Parse(b.Target ?? string.Empty)).ThenBy(b => b.File, StringComparer.Ordinal)]; private static string NormalizeETag(string? etag) => etag?.Trim('"') ?? string.Empty; @@ -424,9 +425,11 @@ private static bool BundlesEqual(IReadOnlyList a, IReadOnlyList< for (var i = 0; i < a.Count; i++) { - if (!string.Equals(a[i].File, b[i].File, StringComparison.Ordinal) || - !string.Equals(a[i].Target, b[i].Target, StringComparison.Ordinal) || - !string.Equals(a[i].ETag, b[i].ETag, StringComparison.Ordinal)) + if ( + !string.Equals(a[i].File, b[i].File, StringComparison.Ordinal) + || !string.Equals(a[i].Target, b[i].Target, StringComparison.Ordinal) + || !string.Equals(a[i].ETag, b[i].ETag, StringComparison.Ordinal) + ) return false; } diff --git a/src/services/Elastic.Changelog/Reconciliation/ChangelogScope.cs b/src/services/Elastic.Changelog/Reconciliation/ChangelogScope.cs index c81da6fba0..41cb87a8dd 100644 --- a/src/services/Elastic.Changelog/Reconciliation/ChangelogScope.cs +++ b/src/services/Elastic.Changelog/Reconciliation/ChangelogScope.cs @@ -44,21 +44,17 @@ private ChangelogScope(ChangelogScopeKind kind, string group) public string Group { get; } /// The S3 key prefix of every object in this scope, ending in /. - public string Prefix => Kind == ChangelogScopeKind.Bundle - ? $"{ChangelogKeys.BundlePrefix}{Group}/" - : $"{ChangelogKeys.ChangelogPrefix}{Group}/"; + public string Prefix => + Kind == ChangelogScopeKind.Bundle ? $"{ChangelogKeys.BundlePrefix}{Group}/" : $"{ChangelogKeys.ChangelogPrefix}{Group}/"; /// The S3 key of this scope's registry.json manifest. - public string RegistryKey => Kind == ChangelogScopeKind.Bundle - ? ChangelogKeys.BundleRegistryKey(Group) - : ChangelogKeys.ChangelogRegistryKey(Group); + public string RegistryKey => + Kind == ChangelogScopeKind.Bundle ? ChangelogKeys.BundleRegistryKey(Group) : ChangelogKeys.ChangelogRegistryKey(Group); /// Creates a bundle scope for ; false when the segment is invalid. public static bool TryCreateBundle(string? product, [NotNullWhen(true)] out ChangelogScope? scope) { - scope = ChangelogKeys.IsValidProduct(product) - ? new ChangelogScope(ChangelogScopeKind.Bundle, product) - : null; + scope = ChangelogKeys.IsValidProduct(product) ? new ChangelogScope(ChangelogScopeKind.Bundle, product) : null; return scope is not null; } diff --git a/src/services/Elastic.Changelog/Reconciliation/ShallowRegistryReconciler.cs b/src/services/Elastic.Changelog/Reconciliation/ShallowRegistryReconciler.cs index 52b310ce0a..af63bcdc26 100644 --- a/src/services/Elastic.Changelog/Reconciliation/ShallowRegistryReconciler.cs +++ b/src/services/Elastic.Changelog/Reconciliation/ShallowRegistryReconciler.cs @@ -106,13 +106,15 @@ public async Task ReconcileAsync(ChangelogScopeKind kind, IReadOnlyCollectionThe S3 key of a tree's shallow map: bundle/registry.json or changelog/registry.json. - public static string TreeRegistryKey(ChangelogScopeKind kind) => kind == ChangelogScopeKind.Bundle - ? $"{ChangelogKeys.BundlePrefix}{ChangelogKeys.RegistryFileName}" - : $"{ChangelogKeys.ChangelogPrefix}{ChangelogKeys.RegistryFileName}"; + public static string TreeRegistryKey(ChangelogScopeKind kind) => + kind == ChangelogScopeKind.Bundle + ? $"{ChangelogKeys.BundlePrefix}{ChangelogKeys.RegistryFileName}" + : $"{ChangelogKeys.ChangelogPrefix}{ChangelogKeys.RegistryFileName}"; /// /// The folder's change token from its current public listing, or null when the folder holds no @@ -122,12 +124,7 @@ public static string TreeRegistryKey(ChangelogScopeKind kind) => kind == Changel private async Task ComputeFolderToken(ChangelogScope scope, Cancel ctx) { var files = new SortedDictionary(StringComparer.Ordinal); - var request = new ListObjectsV2Request - { - BucketName = publicBucketName, - Prefix = scope.Prefix, - Delimiter = "/" - }; + var request = new ListObjectsV2Request { BucketName = publicBucketName, Prefix = scope.Prefix, Delimiter = "/" }; ListObjectsV2Response response; do @@ -140,7 +137,8 @@ public static string TreeRegistryKey(ChangelogScopeKind kind) => kind == Changel files[file] = NormalizeETag(obj.ETag); } request.ContinuationToken = response.NextContinuationToken; - } while (response.IsTruncated == true); + } + while (response.IsTruncated == true); return files.Count == 0 ? null : TokenOf(files); } @@ -180,7 +178,8 @@ private async Task> RebuildTreeMap(ChangelogSco files[file] = NormalizeETag(obj.ETag); } request.ContinuationToken = response.NextContinuationToken; - } while (response.IsTruncated == true); + } + while (response.IsTruncated == true); var map = new SortedDictionary(StringComparer.Ordinal); foreach (var (group, files) in folders) @@ -189,8 +188,8 @@ private async Task> RebuildTreeMap(ChangelogSco } private static bool IsYamlFileName(string file) => - file.Length > 0 - && (file.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) || file.EndsWith(".yml", StringComparison.OrdinalIgnoreCase)); + file.Length > 0 && + (file.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) || file.EndsWith(".yml", StringComparison.OrdinalIgnoreCase)); private static string TokenOf(SortedDictionary files) { @@ -208,11 +207,7 @@ private async Task FetchMap(string key, Cancel ctx) string? etag = null; try { - using var response = await s3Client.GetObjectAsync(new GetObjectRequest - { - BucketName = publicBucketName, - Key = key - }, ctx); + using var response = await s3Client.GetObjectAsync(new GetObjectRequest { BucketName = publicBucketName, Key = key }, ctx); etag = response.ETag; await using var stream = response.ResponseStream; @@ -283,7 +278,10 @@ private async Task TryPutMap(string key, SortedDictionary _metrics.IncrementWriteConflicts(); _logger.LogInformation( "Shallow map {Key} changed concurrently (attempt {Attempt}/{Max}); re-listing and retrying", - key, attempt, MaxWriteAttempts); + key, + attempt, + MaxWriteAttempts + ); return false; } } @@ -295,12 +293,7 @@ private async Task TryDeleteMap(string key, string? etag, int attempt, Can try { - _ = await s3Client.DeleteObjectAsync(new DeleteObjectRequest - { - BucketName = publicBucketName, - Key = key, - IfMatch = etag - }, ctx); + _ = await s3Client.DeleteObjectAsync(new DeleteObjectRequest { BucketName = publicBucketName, Key = key, IfMatch = etag }, ctx); _metrics.IncrementShallowRegistryWrites(); _logger.LogInformation("Deleted shallow map {Key}: the tree is empty", key); return true; @@ -315,7 +308,10 @@ private async Task TryDeleteMap(string key, string? etag, int attempt, Can _metrics.IncrementWriteConflicts(); _logger.LogInformation( "Shallow map {Key} changed concurrently during delete (attempt {Attempt}/{Max}); re-listing and retrying", - key, attempt, MaxWriteAttempts); + key, + attempt, + MaxWriteAttempts + ); return false; } } diff --git a/src/services/Elastic.Changelog/ReleaseNotesExtractor.cs b/src/services/Elastic.Changelog/ReleaseNotesExtractor.cs index 2bd9d6fb1c..e9d9919e06 100644 --- a/src/services/Elastic.Changelog/ReleaseNotesExtractor.cs +++ b/src/services/Elastic.Changelog/ReleaseNotesExtractor.cs @@ -17,7 +17,8 @@ public static partial class ReleaseNotesExtractor [GeneratedRegex(@"(\r?\n){3,}", RegexOptions.None)] private static partial Regex MultipleNewlinesRegex(); - [GeneratedRegex(@"(?:\n|^)\s*#*\s*release[\s-]?notes?[:\s-]*(.*?)(?:(\r?\n|\r){2}|$|((\r?\n|\r)\s*#+))", RegexOptions.IgnoreCase | RegexOptions.Singleline)] + [GeneratedRegex(@"(?:\n|^)\s*#*\s*release[\s-]?notes?[:\s-]*(.*?)(?:(\r?\n|\r){2}|$|((\r?\n|\r)\s*#+))", RegexOptions.IgnoreCase | + RegexOptions.Singleline)] private static partial Regex ReleaseNoteRegex(); /// diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs index 85d8b8809b..3924e11874 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs @@ -63,7 +63,13 @@ private static void RenderEntryTitleAndLinks(StringBuilder sb, ChangelogEntry en /// /// Renders an entry's description with optional comment handling and list continuation /// - private static void RenderEntryDescription(StringBuilder sb, ChangelogEntry entry, ChangelogRenderContext context, bool shouldHide, bool needsContinuation = true) + private static void RenderEntryDescription( + StringBuilder sb, + ChangelogEntry entry, + ChangelogRenderContext context, + bool shouldHide, + bool needsContinuation = true + ) { if (context.HideDescriptions || string.IsNullOrWhiteSpace(entry.Description)) return; diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/BreakingChangesAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/BreakingChangesAsciidocRenderer.cs index 99532acf38..350f55d1d7 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/BreakingChangesAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/BreakingChangesAsciidocRenderer.cs @@ -25,8 +25,7 @@ public override void Render(IReadOnlyCollection entries, Changel foreach (var group in groupedEntries) { // Check if all entries in this group are hidden - var allEntriesHidden = group.All(entry => - ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); + var allEntriesHidden = group.All(entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); if (context.Subsections && !string.IsNullOrWhiteSpace(group.Key)) { diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs index f59297ac40..93229ca8e4 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs @@ -75,10 +75,7 @@ public async Task RenderAsciidoc(ChangelogRenderContext context, Cancel ctx) } // Render highlights (only if any entries have highlight == true) - var highlights = entriesByType.Values - .SelectMany(e => e) - .Where(e => e.Highlight == true) - .ToList(); + var highlights = entriesByType.Values.SelectMany(e => e).Where(e => e.Highlight == true).ToList(); if (highlights.Count > 0) { RenderSectionHeader(sb, "highlights", context.TitleSlug, "Highlights"); diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/DeprecationsAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/DeprecationsAsciidocRenderer.cs index 2800e9507f..b4b6be8288 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/DeprecationsAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/DeprecationsAsciidocRenderer.cs @@ -26,8 +26,7 @@ public override void Render(IReadOnlyCollection entries, Changel foreach (var group in groupedEntries) { // Check if all entries in this group are hidden - var allEntriesHidden = group.All(entry => - ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); + var allEntriesHidden = group.All(entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); // Add nested section header when subsections are enabled and group has a name if (context.Subsections && !string.IsNullOrWhiteSpace(group.Key)) diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/EntriesByAreaAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/EntriesByAreaAsciidocRenderer.cs index 513f8901ba..4840559047 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/EntriesByAreaAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/EntriesByAreaAsciidocRenderer.cs @@ -24,8 +24,7 @@ public override void Render(IReadOnlyCollection entries, Changel foreach (var group in groupedEntries) { // Check if all entries in this group are hidden - var allEntriesHidden = group.All(entry => - ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); + var allEntriesHidden = group.All(entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); // Add nested section header when subsections are enabled and group has a name if (context.Subsections && !string.IsNullOrWhiteSpace(group.Key)) diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/HighlightsAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/HighlightsAsciidocRenderer.cs index 07312f14f4..1ff67c4ab9 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/HighlightsAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/HighlightsAsciidocRenderer.cs @@ -24,8 +24,7 @@ public override void Render(IReadOnlyCollection entries, Changel foreach (var group in groupedEntries) { // Check if all entries in this group are hidden - var allEntriesHidden = group.All(entry => - ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); + var allEntriesHidden = group.All(entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); // Add nested section header when subsections are enabled and group has a name if (context.Subsections && !string.IsNullOrWhiteSpace(group.Key)) diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/KnownIssuesAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/KnownIssuesAsciidocRenderer.cs index 74fac4e4f9..9d5c80ae92 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/KnownIssuesAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/KnownIssuesAsciidocRenderer.cs @@ -24,8 +24,7 @@ public override void Render(IReadOnlyCollection entries, Changel foreach (var group in groupedEntries) { // Check if all entries in this group are hidden - var allEntriesHidden = group.All(entry => - ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); + var allEntriesHidden = group.All(entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); // Add nested section header when subsections are enabled and group has a name if (context.Subsections && !string.IsNullOrWhiteSpace(group.Key)) diff --git a/src/services/Elastic.Changelog/Rendering/BundleDataResolver.cs b/src/services/Elastic.Changelog/Rendering/BundleDataResolver.cs index adac35dc85..893d2d9d34 100644 --- a/src/services/Elastic.Changelog/Rendering/BundleDataResolver.cs +++ b/src/services/Elastic.Changelog/Rendering/BundleDataResolver.cs @@ -33,9 +33,7 @@ public ResolvedEntriesResult ResolveEntries(IReadOnlyList bundl }; } - private static List ResolveBundleEntries( - ValidatedBundle bundle, - HashSet<(string product, string target)> allProducts) + private static List ResolveBundleEntries(ValidatedBundle bundle, HashSet<(string product, string target)> allProducts) { // Collect products from this bundle var bundleProductIds = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -52,15 +50,19 @@ private static List ResolveBundleEntries( ? bundle.Data.Products[0].Owner! : "elastic"; - return bundle.Data.Entries - .Select(entry => new ResolvedEntry - { - Entry = ReleaseNotesSerialization.ConvertBundledEntry(entry), - Repo = repo, - Owner = owner, - BundleProductIds = bundleProductIds, - HideLinks = bundle.Input.HideLinks - }) + return bundle.Data + .Entries + .Select( + entry => + new ResolvedEntry + { + Entry = ReleaseNotesSerialization.ConvertBundledEntry(entry), + Repo = repo, + Owner = owner, + BundleProductIds = bundleProductIds, + HideLinks = bundle.Input.HideLinks + } + ) .ToList(); } } diff --git a/src/services/Elastic.Changelog/Rendering/BundleValidationService.cs b/src/services/Elastic.Changelog/Rendering/BundleValidationService.cs index eca93e3d4e..871d991412 100644 --- a/src/services/Elastic.Changelog/Rendering/BundleValidationService.cs +++ b/src/services/Elastic.Changelog/Rendering/BundleValidationService.cs @@ -24,7 +24,8 @@ public class BundleValidationService(ILoggerFactory logFactory, IFileSystem file public async Task ValidateBundlesAsync( IDiagnosticsCollector collector, IReadOnlyCollection bundles, - Cancel ctx) + Cancel ctx + ) { var bundleDataList = new List(); var seenFileNames = new Dictionary>(StringComparer.OrdinalIgnoreCase); @@ -66,11 +67,7 @@ public async Task ValidateBundlesAsync( if (!result) return CreateInvalidResult(bundleDataList, seenFileNames, seenPrs); - bundleDataList.Add(new ValidatedBundle - { - Data = bundledData, - Input = bundleInput - }); + bundleDataList.Add(new ValidatedBundle { Data = bundledData, Input = bundleInput }); } // Check for duplicate file names across bundles @@ -89,7 +86,8 @@ public async Task ValidateBundlesAsync( IDiagnosticsCollector collector, Bundle mainBundle, IReadOnlyList amendFiles, - Cancel ctx) + Cancel ctx + ) { var amendBundles = new List(); @@ -104,7 +102,8 @@ public async Task ValidateBundlesAsync( "Merging amend file {AmendFile} ({AddCount} additions, {ExcludeCount} exclusions)", amendFile, amendBundle.Entries.Count, - amendBundle.ExcludeEntries.Count); + amendBundle.ExcludeEntries.Count + ); } catch (YamlException yamlEx) { @@ -147,7 +146,8 @@ private static bool ValidateBundleEntries( BundleInput bundleInput, Bundle bundledData, Dictionary> seenFileNames, - Dictionary> seenPrs) + Dictionary> seenPrs + ) { var fileNamesInThisBundle = new HashSet(StringComparer.OrdinalIgnoreCase); var allValid = true; @@ -184,15 +184,18 @@ private static bool ValidateResolvedEntry( IDiagnosticsCollector collector, string bundleFile, BundledEntry entry, - Dictionary> seenPrs) + Dictionary> seenPrs + ) { // Bundles are always self-contained: an entry without inline content is invalid. if (string.IsNullOrWhiteSpace(entry.Title) || entry.Type == null) { var entryName = !string.IsNullOrWhiteSpace(entry.File?.Name) ? entry.File.Name : entry.Title ?? ""; - collector.EmitError(bundleFile, + collector.EmitError( + bundleFile, $"Entry '{entryName}' in bundle has no inline content: title and type are required. " + - "Re-create the bundle with 'changelog bundle'."); + "Re-create the bundle with 'changelog bundle'." + ); return false; } @@ -222,14 +225,18 @@ private static bool ValidateResolvedEntry( private static void EmitDuplicateWarnings( IDiagnosticsCollector collector, Dictionary> seenFileNames, - Dictionary> seenPrs) + Dictionary> seenPrs + ) { // Check for duplicate file names across bundles foreach (var (fileName, bundleFiles) in seenFileNames.Where(kvp => kvp.Value.Count > 1)) { var uniqueBundles = bundleFiles.Distinct().ToList(); if (uniqueBundles.Count > 1) - collector.EmitWarning(string.Empty, $"Changelog file '{fileName}' appears in multiple bundles: {string.Join(", ", uniqueBundles)}"); + collector.EmitWarning( + string.Empty, + $"Changelog file '{fileName}' appears in multiple bundles: {string.Join(", ", uniqueBundles)}" + ); } // Check for duplicate PRs @@ -244,12 +251,6 @@ private static void EmitDuplicateWarnings( private static BundleValidationResult CreateInvalidResult( List bundles, Dictionary> seenFileNames, - Dictionary> seenPrs) => - new() - { - IsValid = false, - Bundles = bundles, - SeenFileNames = seenFileNames, - SeenPrs = seenPrs - }; + Dictionary> seenPrs + ) => new() { IsValid = false, Bundles = bundles, SeenFileNames = seenFileNames, SeenPrs = seenPrs }; } diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderUtilities.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderUtilities.cs index a6722dfe2c..3002ffcd82 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderUtilities.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderUtilities.cs @@ -19,14 +19,15 @@ public static class ChangelogRenderUtilities #pragma warning disable IDE0060 // Remove unused parameter public static string GetComponent(ChangelogEntry entry, ChangelogRenderContext? context = null) #pragma warning restore IDE0060 // Remove unused parameter - => entry.Areas is { Count: > 0 } ? entry.Areas[0] : string.Empty; + => entry.Areas is { Count: > 0 } ? entry.Areas[0] : string.Empty; /// /// Gets the entry context (repo, owner, hideLinks, shouldHide) for a specific entry. /// public static (string EntryRepo, string EntryOwner, bool HideLinks, bool ShouldHide) GetEntryContext( ChangelogEntry entry, - ChangelogRenderContext context) + ChangelogRenderContext context + ) { var entryRepo = context.EntryToRepo.GetValueOrDefault(entry, context.Repo); var entryOwner = context.EntryToOwner.GetValueOrDefault(entry, context.Owner); @@ -40,10 +41,7 @@ public static (string EntryRepo, string EntryOwner, bool HideLinks, bool ShouldH /// rules.publish is no longer supported; filtering must be done at bundle time via rules.bundle. /// #pragma warning disable IDE0060 // Remove unused parameter - public static bool ShouldHideEntry( - ChangelogEntry entry, - HashSet featureIdsToHide, - ChangelogRenderContext? context = null) + public static bool ShouldHideEntry(ChangelogEntry entry, HashSet featureIdsToHide, ChangelogRenderContext? context = null) #pragma warning restore IDE0060 // Remove unused parameter { // Check feature IDs only diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs index 64281dedba..613ee6ca4e 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs @@ -18,25 +18,19 @@ public class ChangelogRenderer(IChangelogFileSystem fileSystem, ILogger logger) /// /// Renders changelog output based on the specified file type. /// - public async Task RenderAsync( - ChangelogFileType fileType, - ChangelogRenderContext context, - Cancel ctx) + public async Task RenderAsync(ChangelogFileType fileType, ChangelogRenderContext context, Cancel ctx) { switch (fileType) { case ChangelogFileType.Asciidoc: await RenderAsciidocAsync(context, ctx); break; - case ChangelogFileType.Markdown: await RenderMarkdownAsync(context, ctx); break; - case ChangelogFileType.Gfm: await RenderGfmAsync(context, ctx); break; - default: throw new ArgumentException($"Unknown changelog file type: {fileType}", nameof(fileType)); } diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index b647abe7e4..5b5054b7d5 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -33,7 +33,6 @@ public record RenderChangelogsArguments public string? Config { get; init; } public ChangelogFileType FileType { get; init; } = ChangelogFileType.Markdown; public bool HideDescriptions { get; init; } - } /// @@ -77,11 +76,7 @@ public class ChangelogRenderingService( private readonly ILogger _logger = logFactory.CreateLogger(); private readonly IChangelogFileSystem _fileSystem = fileSystem; - public async Task RenderChangelogs( - IDiagnosticsCollector collector, - RenderChangelogsArguments input, - Cancel ctx - ) + public async Task RenderChangelogs(IDiagnosticsCollector collector, RenderChangelogsArguments input, Cancel ctx) { try { @@ -149,9 +144,11 @@ Cancel ctx string? renderDescription = null; if (bundleDescriptions.Count > 1) { - collector.EmitWarning(string.Empty, + collector.EmitWarning( + string.Empty, $"Multiple bundles contain descriptions ({bundleDescriptions.Count} found). " + - "Multi-bundle description support is not yet implemented. Descriptions will be skipped."); + "Multi-bundle description support is not yet implemented. Descriptions will be skipped." + ); } else if (bundleDescriptions.Count == 1) { @@ -169,9 +166,11 @@ Cancel ctx DateOnly? renderReleaseDate = null; if (bundleReleaseDates.Count > 1) { - collector.EmitWarning(string.Empty, + collector.EmitWarning( + string.Empty, $"Multiple bundles contain release dates ({bundleReleaseDates.Count} found). " + - "Multi-bundle release date support is not yet implemented. Release dates will be skipped."); + "Multi-bundle release date support is not yet implemented. Release dates will be skipped." + ); } else if (bundleReleaseDates.Count == 1) { @@ -179,7 +178,15 @@ Cancel ctx } // Build render context - var context = BuildRenderContext(input, outputSetup, resolvedResult, combinedHideFeatures, config, renderDescription, renderReleaseDate); + var context = BuildRenderContext( + input, + outputSetup, + resolvedResult, + combinedHideFeatures, + config, + renderDescription, + renderReleaseDate + ); // Validate entry types if (!ValidateEntryTypes(collector, resolvedResult.Entries, config.Types)) @@ -211,7 +218,8 @@ Cancel ctx private OutputSetup SetupOutput( IDiagnosticsCollector collector, RenderChangelogsArguments input, - IReadOnlySet<(string product, string target)> allProducts) + IReadOnlySet<(string product, string target)> allProducts + ) { // Determine output directory var outputDir = input.Output ?? _fileSystem.Directory.GetCurrentDirectory(); @@ -219,16 +227,17 @@ private OutputSetup SetupOutput( _ = _fileSystem.Directory.CreateDirectory(outputDir); // Extract version from products (use first product's target if available, or "unknown") - var version = allProducts.Count > 0 - ? allProducts.OrderBy(p => p.product).ThenBy(p => p.target).First().target - : "unknown"; + var version = allProducts.Count > 0 ? allProducts.OrderBy(p => p.product).ThenBy(p => p.target).First().target : "unknown"; if (string.IsNullOrWhiteSpace(version)) version = "unknown"; // Warn if --title was not provided and version defaults to "unknown" if (string.IsNullOrWhiteSpace(input.Title) && version == "unknown") - collector.EmitWarning(string.Empty, "No --title option provided and bundle files do not contain 'target' values. Output folder and markdown titles will default to 'unknown'. Consider using --title to specify a custom title."); + collector.EmitWarning( + string.Empty, + "No --title option provided and bundle files do not contain 'target' values. Output folder and markdown titles will default to 'unknown'. Consider using --title to specify a custom title." + ); // Determine title and slug string title; @@ -254,20 +263,25 @@ private OutputSetup SetupOutput( private static void EmitHiddenEntryWarnings( IDiagnosticsCollector collector, IReadOnlyList entries, - HashSet featureIdsToHide) + HashSet featureIdsToHide + ) { // Track hidden entries for warnings foreach (var resolved in entries) { if (!string.IsNullOrWhiteSpace(resolved.Entry.FeatureId) && featureIdsToHide.Contains(resolved.Entry.FeatureId)) - collector.EmitWarning(string.Empty, $"Changelog entry '{resolved.Entry.Title}' with feature-id '{resolved.Entry.FeatureId}' will be commented out in markdown output"); + collector.EmitWarning( + string.Empty, + $"Changelog entry '{resolved.Entry.Title}' with feature-id '{resolved.Entry.FeatureId}' will be commented out in markdown output" + ); } } private static bool ValidateEntryTypes( IDiagnosticsCollector collector, IReadOnlyList entries, - IReadOnlyList availableTypes) + IReadOnlyList availableTypes + ) { var isValid = true; @@ -276,17 +290,20 @@ private static bool ValidateEntryTypes( if (invalidEntries.Count > 0) { foreach (var entry in invalidEntries) - collector.EmitError(string.Empty, $"Changelog entry '{entry.Entry.Title}' has an invalid or unrecognized type. Valid types are: {string.Join(", ", availableTypes)}."); + collector.EmitError( + string.Empty, + $"Changelog entry '{entry.Entry.Title}' has an invalid or unrecognized type. Valid types are: {string.Join(", ", availableTypes)}." + ); isValid = false; } // All valid enum values (except Invalid) are handled in rendering var handledTypes = new HashSet( - ChangelogEntryTypeExtensions.GetValues().Where(t => t != ChangelogEntryType.Invalid)); + ChangelogEntryTypeExtensions.GetValues().Where(t => t != ChangelogEntryType.Invalid) + ); var availableTypesSet = new HashSet(availableTypes, StringComparer.OrdinalIgnoreCase); - var entriesByType = entries - .Where(e => e.Entry.Type != ChangelogEntryType.Invalid) + var entriesByType = entries.Where(e => e.Entry.Type != ChangelogEntryType.Invalid) .GroupBy(e => e.Entry.Type) .ToDictionary(g => g.Key, g => g.Count()); @@ -294,7 +311,10 @@ private static bool ValidateEntryTypes( { var typeString = entryType.ToStringFast(true); if (availableTypesSet.Contains(typeString) && !handledTypes.Contains(entryType)) - collector.EmitWarning(string.Empty, $"Changelog type '{typeString}' is valid according to configuration but is not handled in rendering output. {count} entry/entries of this type will not be included in the generated markdown files."); + collector.EmitWarning( + string.Empty, + $"Changelog type '{typeString}' is valid according to configuration but is not handled in rendering output. {count} entry/entries of this type will not be included in the generated markdown files." + ); } return isValid; @@ -307,7 +327,8 @@ private static ChangelogRenderContext BuildRenderContext( HashSet featureIdsToHide, ChangelogConfiguration? config, string? description = null, - DateOnly? releaseDate = null) + DateOnly? releaseDate = null + ) { // Group entries by type var entriesByType = resolved.Entries diff --git a/src/services/Elastic.Changelog/Rendering/FeatureHidingLoader.cs b/src/services/Elastic.Changelog/Rendering/FeatureHidingLoader.cs index c7e1bf4058..cdb211ca8f 100644 --- a/src/services/Elastic.Changelog/Rendering/FeatureHidingLoader.cs +++ b/src/services/Elastic.Changelog/Rendering/FeatureHidingLoader.cs @@ -17,47 +17,33 @@ public class FeatureHidingLoader(IFileSystem fileSystem) /// Loads feature IDs to hide from the provided input values. /// Values can be file paths (reads feature IDs from file, one per line) or direct feature IDs. /// - public async Task LoadFeatureIdsAsync( - IDiagnosticsCollector collector, - string[]? hideFeatures, - Cancel ctx) + public async Task LoadFeatureIdsAsync(IDiagnosticsCollector collector, string[]? hideFeatures, Cancel ctx) { var featureIdsToHide = new HashSet(StringComparer.OrdinalIgnoreCase); if (hideFeatures is not { Length: > 0 }) { - return new FeatureHidingResult - { - IsValid = true, - FeatureIdsToHide = featureIdsToHide - }; + return new FeatureHidingResult { IsValid = true, FeatureIdsToHide = featureIdsToHide }; } // If there's exactly one value, check if it's a file path if (hideFeatures.Length == 1) { var result = await ProcessSingleValueAsync(collector, hideFeatures[0], featureIdsToHide, ctx); - return new FeatureHidingResult - { - IsValid = result, - FeatureIdsToHide = featureIdsToHide - }; + return new FeatureHidingResult { IsValid = result, FeatureIdsToHide = featureIdsToHide }; } // Multiple values - process all values first, then check for errors var result2 = await ProcessMultipleValuesAsync(collector, hideFeatures, featureIdsToHide, ctx); - return new FeatureHidingResult - { - IsValid = result2, - FeatureIdsToHide = featureIdsToHide - }; + return new FeatureHidingResult { IsValid = result2, FeatureIdsToHide = featureIdsToHide }; } private async Task ProcessSingleValueAsync( IDiagnosticsCollector collector, string singleValue, HashSet featureIdsToHide, - Cancel ctx) + Cancel ctx + ) { // Try to normalize the path to handle ~ and relative paths var normalizedValue = NormalizePath(singleValue); @@ -77,7 +63,7 @@ private async Task ProcessSingleValueAsync( collector.EmitError( normalizedValue, $"File does not exist: {normalizedValue}. Current directory: {currentDir}. " + - "Paths support tilde (~) expansion and can be relative or absolute." + "Paths support tilde (~) expansion and can be relative or absolute." ); return false; } @@ -91,7 +77,8 @@ private async Task ProcessMultipleValuesAsync( IDiagnosticsCollector collector, string[] values, HashSet featureIdsToHide, - Cancel ctx) + Cancel ctx + ) { var nonExistentFiles = new List(); @@ -126,7 +113,7 @@ private async Task ProcessMultipleValuesAsync( collector.EmitError( filePath, $"File does not exist: {filePath}. Current directory: {currentDir}. " + - "Paths support tilde (~) expansion and can be relative or absolute." + "Paths support tilde (~) expansion and can be relative or absolute." ); } return false; @@ -135,24 +122,21 @@ private async Task ProcessMultipleValuesAsync( return true; } - private async Task ReadFeatureIdsFromFileAsync( - string filePath, - HashSet featureIdsToHide, - Cancel ctx) + private async Task ReadFeatureIdsFromFileAsync(string filePath, HashSet featureIdsToHide, Cancel ctx) { var content = await fileSystem.File.ReadAllTextAsync(filePath, ctx); - var featureIds = content - .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Where(f => !string.IsNullOrWhiteSpace(f)); + var featureIds = content.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Where( + f => !string.IsNullOrWhiteSpace(f) + ); foreach (var featureId in featureIds) _ = featureIdsToHide.Add(featureId); } private bool LooksLikeFilePath(string value) => - value.Contains(fileSystem.Path.DirectorySeparatorChar) || - value.Contains(fileSystem.Path.AltDirectorySeparatorChar) || - fileSystem.Path.HasExtension(value); + value.Contains(fileSystem.Path.DirectorySeparatorChar) + || value.Contains(fileSystem.Path.AltDirectorySeparatorChar) + || fileSystem.Path.HasExtension(value); /// /// Normalizes a file path by expanding tilde (~) to the user's home directory diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs index 46a3a235ff..c576449253 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs @@ -29,8 +29,8 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(InvariantCulture, $"## {context.Title} [{context.Repo}-{context.TitleSlug}-breaking-changes]"); // Check if all entries are hidden - var allEntriesHidden = breakingChanges.Count > 0 && breakingChanges.All(entry => - ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); + var allEntriesHidden = breakingChanges.Count > 0 && + breakingChanges.All(entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); if (breakingChanges.Count > 0) { @@ -42,8 +42,9 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct foreach (var group in groupedEntries) { // Check if all entries in this group are hidden - var allGroupEntriesHidden = group.All(entry => - ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); + var allGroupEntriesHidden = group.All( + entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context) + ); if (context.Subsections && !string.IsNullOrWhiteSpace(group.Key)) { @@ -73,15 +74,21 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(); RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks)); - _ = sb.AppendLine(!string.IsNullOrWhiteSpace(entry.Impact) - ? "**Impact**
" + entry.Impact - : "% **Impact**
_Add a description of the impact_"); + _ = + sb.AppendLine( + !string.IsNullOrWhiteSpace(entry.Impact) + ? "**Impact**
" + entry.Impact + : "% **Impact**
_Add a description of the impact_" + ); _ = sb.AppendLine(); - _ = sb.AppendLine(!string.IsNullOrWhiteSpace(entry.Action) - ? "**Action**
" + entry.Action - : "% **Action**
_Add a description of the what action to take_"); + _ = + sb.AppendLine( + !string.IsNullOrWhiteSpace(entry.Action) + ? "**Action**
" + entry.Action + : "% **Action**
_Add a description of the what action to take_" + ); _ = sb.AppendLine("::::"); } @@ -100,7 +107,10 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct } // PR/Issue links with "For more information" pattern - indented for list continuation - RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks, IndentForListItem: true)); + RenderPrIssueLinks( + sb, + new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks, IndentForListItem: true) + ); // Impact and Action sections - indented for list continuation if (!string.IsNullOrWhiteSpace(entry.Impact)) diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs index a518d4d092..3a3b981301 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs @@ -36,10 +36,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct var knownIssues = entriesByType.GetValueOrDefault(KnownIssue, []); // Check for highlights - var highlights = entriesByType.Values - .SelectMany(e => e) - .Where(e => e.Highlight == true) - .ToList(); + var highlights = entriesByType.Values.SelectMany(e => e).Where(e => e.Highlight == true).ToList(); var sb = new StringBuilder(); @@ -64,8 +61,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct // Helper to check if all entries in a collection are hidden bool AllEntriesHidden(IReadOnlyCollection entries) => - entries.Count > 0 && entries.All(entry => - ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); + entries.Count > 0 && entries.All(entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); // Render highlights first if any exist if (highlights.Count > 0) @@ -148,17 +144,17 @@ bool AllEntriesHidden(IReadOnlyCollection entries) => } // Check if we have any visible content - var hasAnyVisibleContent = highlights.Count > 0 || - (!AllEntriesHidden(features) && features.Count > 0) || - (!AllEntriesHidden(enhancements) && enhancements.Count > 0) || - (!AllEntriesHidden(breakingChanges) && breakingChanges.Count > 0) || - (!AllEntriesHidden(deprecations) && deprecations.Count > 0) || - (!AllEntriesHidden(security) && security.Count > 0) || - (!AllEntriesHidden(bugFixes) && bugFixes.Count > 0) || - (!AllEntriesHidden(knownIssues) && knownIssues.Count > 0) || - (!AllEntriesHidden(docs) && docs.Count > 0) || - (!AllEntriesHidden(regressions) && regressions.Count > 0) || - (!AllEntriesHidden(other) && other.Count > 0); + var hasAnyVisibleContent = highlights.Count > 0 + || (!AllEntriesHidden(features) && features.Count > 0) + || (!AllEntriesHidden(enhancements) && enhancements.Count > 0) + || (!AllEntriesHidden(breakingChanges) && breakingChanges.Count > 0) + || (!AllEntriesHidden(deprecations) && deprecations.Count > 0) + || (!AllEntriesHidden(security) && security.Count > 0) + || (!AllEntriesHidden(bugFixes) && bugFixes.Count > 0) + || (!AllEntriesHidden(knownIssues) && knownIssues.Count > 0) + || (!AllEntriesHidden(docs) && docs.Count > 0) + || (!AllEntriesHidden(regressions) && regressions.Count > 0) + || (!AllEntriesHidden(other) && other.Count > 0); if (!hasAnyVisibleContent) { @@ -169,10 +165,7 @@ bool AllEntriesHidden(IReadOnlyCollection entries) => await WriteOutputFileAsync(context.OutputDir, context.TitleSlug, sb.ToString(), ctx); } - private static void RenderEntriesByArea( - StringBuilder sb, - IReadOnlyCollection entries, - ChangelogRenderContext context) + private static void RenderEntriesByArea(StringBuilder sb, IReadOnlyCollection entries, ChangelogRenderContext context) { var groupedByArea = context.Subsections ? entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).OrderBy(g => g.Key).ToList() @@ -181,8 +174,9 @@ private static void RenderEntriesByArea( foreach (var areaGroup in groupedByArea) { // Check if all entries in this area group are hidden - var allEntriesHidden = areaGroup.All(entry => - ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); + var allEntriesHidden = areaGroup.All( + entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context) + ); if (context.Subsections && !string.IsNullOrWhiteSpace(areaGroup.Key)) { diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogMarkdownRenderer.cs index 5b6e54c3fa..2388513a7d 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogMarkdownRenderer.cs @@ -19,9 +19,7 @@ public class ChangelogMarkdownRenderer(IChangelogFileSystem fileSystem) public async Task RenderAsync(ChangelogRenderContext context, Cancel ctx) { // Check if there are any highlighted entries - var hasHighlights = context.EntriesByType.Values - .SelectMany(e => e) - .Any(e => e.Highlight == true); + var hasHighlights = context.EntriesByType.Values.SelectMany(e => e).Any(e => e.Highlight == true); var renderers = new List { diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs index 55c98cfe92..fc84b4a037 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs @@ -28,8 +28,8 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(InvariantCulture, $"## {context.Title} [{context.Repo}-{context.TitleSlug}-deprecations]"); // Check if all entries are hidden - var allEntriesHidden = deprecations.Count > 0 && deprecations.All(entry => - ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); + var allEntriesHidden = deprecations.Count > 0 && + deprecations.All(entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); if (deprecations.Count > 0) { @@ -39,8 +39,9 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct foreach (var areaGroup in groupedByArea) { // Check if all entries in this area group are hidden - var allGroupEntriesHidden = areaGroup.All(entry => - ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); + var allGroupEntriesHidden = areaGroup.All( + entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context) + ); if (context.Subsections && !string.IsNullOrWhiteSpace(areaGroup.Key)) { @@ -70,15 +71,21 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(); RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks)); - _ = sb.AppendLine(!string.IsNullOrWhiteSpace(entry.Impact) - ? "**Impact**
" + entry.Impact - : "% **Impact**
_Add a description of the impact_"); + _ = + sb.AppendLine( + !string.IsNullOrWhiteSpace(entry.Impact) + ? "**Impact**
" + entry.Impact + : "% **Impact**
_Add a description of the impact_" + ); _ = sb.AppendLine(); - _ = sb.AppendLine(!string.IsNullOrWhiteSpace(entry.Action) - ? "**Action**
" + entry.Action - : "% **Action**
_Add a description of the what action to take_"); + _ = + sb.AppendLine( + !string.IsNullOrWhiteSpace(entry.Action) + ? "**Action**
" + entry.Action + : "% **Action**
_Add a description of the what action to take_" + ); _ = sb.AppendLine("::::"); } @@ -97,7 +104,10 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct } // PR/Issue links with "For more information" pattern - indented for list continuation - RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks, IndentForListItem: true)); + RenderPrIssueLinks( + sb, + new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks, IndentForListItem: true) + ); // Impact and Action sections - indented for list continuation if (!string.IsNullOrWhiteSpace(entry.Impact)) diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs index 404ffd3864..40721593a6 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs @@ -22,17 +22,14 @@ public class HighlightsMarkdownRenderer(IChangelogFileSystem fileSystem) : Markd public override async Task RenderAsync(ChangelogRenderContext context, Cancel ctx) { // Get all entries with highlight == true from all types - var highlights = context.EntriesByType.Values - .SelectMany(e => e) - .Where(e => e.Highlight == true) - .ToList(); + var highlights = context.EntriesByType.Values.SelectMany(e => e).Where(e => e.Highlight == true).ToList(); var sb = new StringBuilder(); _ = sb.AppendLine(InvariantCulture, $"## {context.Title} [{context.Repo}-{context.TitleSlug}-highlights]"); // Check if all entries are hidden - var allEntriesHidden = highlights.Count > 0 && highlights.All(entry => - ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); + var allEntriesHidden = highlights.Count > 0 && + highlights.All(entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); if (highlights.Count > 0) { @@ -42,8 +39,9 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct foreach (var areaGroup in groupedByArea) { // Check if all entries in this area group are hidden - var allGroupEntriesHidden = areaGroup.All(entry => - ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); + var allGroupEntriesHidden = areaGroup.All( + entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context) + ); if (context.Subsections && !string.IsNullOrWhiteSpace(areaGroup.Key)) { @@ -89,7 +87,10 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct } // PR/Issue links with "For more information" pattern - indented for list continuation - RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks, IndentForListItem: true)); + RenderPrIssueLinks( + sb, + new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks, IndentForListItem: true) + ); } if (shouldHide) diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs index dea2a32d79..4c1b3449e6 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs @@ -48,19 +48,22 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(context.BundleDescription); } - - var hasAnyEntries = features.Count > 0 || enhancements.Count > 0 || security.Count > 0 || bugFixes.Count > 0 || docs.Count > 0 || regressions.Count > 0 || other.Count > 0; + var hasAnyEntries = features.Count > 0 + || enhancements.Count > 0 + || security.Count > 0 + || bugFixes.Count > 0 + || docs.Count > 0 + || regressions.Count > 0 + || other.Count > 0; // Helper to check if all entries in a collection are hidden bool AllEntriesHidden(IReadOnlyCollection entries) => - entries.Count > 0 && entries.All(entry => - ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); + entries.Count > 0 && entries.All(entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); // Check if each category has visible entries var hasVisibleFeatures = (features.Count > 0 || enhancements.Count > 0) && !(AllEntriesHidden(features) && AllEntriesHidden(enhancements)); - var hasVisibleFixes = (security.Count > 0 || bugFixes.Count > 0) && - !(AllEntriesHidden(security) && AllEntriesHidden(bugFixes)); + var hasVisibleFixes = (security.Count > 0 || bugFixes.Count > 0) && !(AllEntriesHidden(security) && AllEntriesHidden(bugFixes)); var hasVisibleDocs = docs.Count > 0 && !AllEntriesHidden(docs); var hasVisibleRegressions = regressions.Count > 0 && !AllEntriesHidden(regressions); var hasVisibleOther = other.Count > 0 && !AllEntriesHidden(other); @@ -72,7 +75,11 @@ bool AllEntriesHidden(IReadOnlyCollection entries) => if (features.Count > 0 || enhancements.Count > 0) { var combined = features.Concat(enhancements).ToList(); - _ = sb.AppendLine(InvariantCulture, $"### Features and enhancements [{context.Repo}-{context.TitleSlug}-features-enhancements]"); + _ = + sb.AppendLine( + InvariantCulture, + $"### Features and enhancements [{context.Repo}-{context.TitleSlug}-features-enhancements]" + ); RenderEntriesByArea(sb, combined, context); } @@ -120,10 +127,7 @@ bool AllEntriesHidden(IReadOnlyCollection entries) => await WriteOutputFileAsync(context.OutputDir, context.TitleSlug, sb.ToString(), ctx); } - private static void RenderEntriesByArea( - StringBuilder sb, - IReadOnlyCollection entries, - ChangelogRenderContext context) + private static void RenderEntriesByArea(StringBuilder sb, IReadOnlyCollection entries, ChangelogRenderContext context) { var groupedByArea = context.Subsections ? entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).OrderBy(g => g.Key).ToList() @@ -131,8 +135,9 @@ private static void RenderEntriesByArea( foreach (var areaGroup in groupedByArea) { // Check if all entries in this area group are hidden - var allEntriesHidden = areaGroup.All(entry => - ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); + var allEntriesHidden = areaGroup.All( + entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context) + ); if (context.Subsections && !string.IsNullOrWhiteSpace(areaGroup.Key)) { diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs index 1872a78c6a..2317b76cc8 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs @@ -28,8 +28,8 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(InvariantCulture, $"## {context.Title} [{context.Repo}-{context.TitleSlug}-known-issues]"); // Check if all entries are hidden - var allEntriesHidden = knownIssues.Count > 0 && knownIssues.All(entry => - ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); + var allEntriesHidden = knownIssues.Count > 0 && + knownIssues.All(entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); if (knownIssues.Count > 0) { @@ -39,8 +39,9 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct foreach (var areaGroup in groupedByArea) { // Check if all entries in this area group are hidden - var allGroupEntriesHidden = areaGroup.All(entry => - ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); + var allGroupEntriesHidden = areaGroup.All( + entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context) + ); if (context.Subsections && !string.IsNullOrWhiteSpace(areaGroup.Key)) { @@ -70,15 +71,21 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(); RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks)); - _ = sb.AppendLine(!string.IsNullOrWhiteSpace(entry.Impact) - ? "**Impact**
" + entry.Impact - : "% **Impact**
_Add a description of the impact_"); + _ = + sb.AppendLine( + !string.IsNullOrWhiteSpace(entry.Impact) + ? "**Impact**
" + entry.Impact + : "% **Impact**
_Add a description of the impact_" + ); _ = sb.AppendLine(); - _ = sb.AppendLine(!string.IsNullOrWhiteSpace(entry.Action) - ? "**Action**
" + entry.Action - : "% **Action**
_Add a description of the what action to take_"); + _ = + sb.AppendLine( + !string.IsNullOrWhiteSpace(entry.Action) + ? "**Action**
" + entry.Action + : "% **Action**
_Add a description of the what action to take_" + ); _ = sb.AppendLine("::::"); } @@ -97,7 +104,10 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct } // PR/Issue links with "For more information" pattern - indented for list continuation - RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks, IndentForListItem: true)); + RenderPrIssueLinks( + sb, + new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks, IndentForListItem: true) + ); // Impact and Action sections - indented for list continuation if (!string.IsNullOrWhiteSpace(entry.Impact)) diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs b/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs index c9b9529330..880bef4467 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs @@ -13,13 +13,7 @@ namespace Elastic.Changelog.Rendering.Markdown; /// /// Options for rendering PR and issue links /// -public record PrIssueLinkOptions( - ChangelogEntry Entry, - string Repo, - string Owner, - bool HideLinks, - bool IndentForListItem = false -); +public record PrIssueLinkOptions(ChangelogEntry Entry, string Repo, string Owner, bool HideLinks, bool IndentForListItem = false); /// /// Abstract base class for changelog markdown renderers diff --git a/src/services/Elastic.Changelog/Scrubbing/ChangelogContentScrubber.cs b/src/services/Elastic.Changelog/Scrubbing/ChangelogContentScrubber.cs index 9fd627e515..dde924b473 100644 --- a/src/services/Elastic.Changelog/Scrubbing/ChangelogContentScrubber.cs +++ b/src/services/Elastic.Changelog/Scrubbing/ChangelogContentScrubber.cs @@ -38,9 +38,7 @@ public async Task ScrubAsync(string key, string content, Cancel ctx) // which no longer appears in the new keys) so bundles are not misclassified as entries. var isBundlePath = key.StartsWith(ChangelogKeys.BundlePrefix, StringComparison.OrdinalIgnoreCase); - return isBundlePath - ? await ScrubBundle(content, ctx) - : await ScrubChangelog(content, ctx); + return isBundlePath ? await ScrubBundle(content, ctx) : await ScrubChangelog(content, ctx); } private async Task ScrubBundle(string content, Cancel ctx) @@ -89,9 +87,17 @@ private async Task ScrubChangelog(string content, Cancel ctx) }; await using var collector = new DiagnosticsCollector([]); - if (!LinkAllowlistSanitizer.TryApplyChangelogEntry( - collector, bundledEntry, allowRepos, "elastic", null, - out var sanitized, out var changed)) + if ( + !LinkAllowlistSanitizer.TryApplyChangelogEntry( + collector, + bundledEntry, + allowRepos, + "elastic", + null, + out var sanitized, + out var changed + ) + ) throw new InvalidOperationException($"Failed to apply allowlist to changelog entry; errors: {collector.Errors}"); if (!changed) diff --git a/src/services/Elastic.Changelog/Scrubbing/ScrubberProcessor.cs b/src/services/Elastic.Changelog/Scrubbing/ScrubberProcessor.cs index bef62e55b7..aa566b8841 100644 --- a/src/services/Elastic.Changelog/Scrubbing/ScrubberProcessor.cs +++ b/src/services/Elastic.Changelog/Scrubbing/ScrubberProcessor.cs @@ -120,7 +120,12 @@ public async Task> ProcessAsync(IReadOnlyList> ProcessAsync(IReadOnlyList objectWork, Dictionary groupWork, - Dictionary shallowWork) + Dictionary shallowWork + ) { var hasScope = ChangelogScope.TryFromKey(key, out var scope); @@ -176,8 +187,7 @@ private void Classify( return; } - if (!key.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) && - !key.EndsWith(".yml", StringComparison.OrdinalIgnoreCase)) + if (!key.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) && !key.EndsWith(".yml", StringComparison.OrdinalIgnoreCase)) { _logger.LogInformation("Skipping non-YAML key: {Key}", key); return; @@ -198,7 +208,8 @@ private static void AddObject( string key, string sourceBucket, string messageId, - bool passThrough) + bool passThrough + ) { if (!objectWork.TryGetValue(key, out var work)) { @@ -280,22 +291,22 @@ private async Task ReconcileObjectAsync(string sourceBucket, string key, bool pa _metrics.IncrementObjectReconcileRetries(); _logger.LogInformation( "Private {Key} changed while its reconcile was in flight (attempt {Attempt}/{Max}); redoing from current state", - key, attempt, MaxObjectAttempts); + key, + attempt, + MaxObjectAttempts + ); } throw new InvalidOperationException( - $"Private {key} kept changing during {MaxObjectAttempts} reconcile attempts; failing the message for redelivery."); + $"Private {key} kept changing during {MaxObjectAttempts} reconcile attempts; failing the message for redelivery." + ); } private async Task<(string Content, string ETag)?> TryGetPrivateObject(string sourceBucket, string key, Cancel ctx) { try { - using var response = await s3Client.GetObjectAsync(new GetObjectRequest - { - BucketName = sourceBucket, - Key = key - }, ctx); + using var response = await s3Client.GetObjectAsync(new GetObjectRequest { BucketName = sourceBucket, Key = key }, ctx); await using var stream = response.ResponseStream; using var reader = new StreamReader(stream); @@ -312,11 +323,8 @@ private async Task ReconcileObjectAsync(string sourceBucket, string key, bool pa { try { - var response = await s3Client.GetObjectMetadataAsync(new GetObjectMetadataRequest - { - BucketName = sourceBucket, - Key = key - }, ctx); + var response = + await s3Client.GetObjectMetadataAsync(new GetObjectMetadataRequest { BucketName = sourceBucket, Key = key }, ctx); return NormalizeETag(response.ETag); } catch (AmazonS3Exception ex) when (ex.StatusCode == HttpStatusCode.NotFound) @@ -326,23 +334,17 @@ private async Task ReconcileObjectAsync(string sourceBucket, string key, bool pa } private async Task PutPublicObject(string key, string content, string contentType, Cancel ctx) => - _ = await s3Client.PutObjectAsync(new PutObjectRequest - { - BucketName = publicBucketName, - Key = key, - ContentBody = content, - ContentType = contentType - }, ctx); + _ = + await s3Client.PutObjectAsync( + new PutObjectRequest { BucketName = publicBucketName, Key = key, ContentBody = content, ContentType = contentType }, + ctx + ); private async Task DeletePublicObject(string key, Cancel ctx) { try { - _ = await s3Client.DeleteObjectAsync(new DeleteObjectRequest - { - BucketName = publicBucketName, - Key = key - }, ctx); + _ = await s3Client.DeleteObjectAsync(new DeleteObjectRequest { BucketName = publicBucketName, Key = key }, ctx); } catch (AmazonS3Exception ex) when (ex.StatusCode == HttpStatusCode.NotFound) { diff --git a/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs b/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs index 33b3f149b8..b434d74bce 100644 --- a/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs +++ b/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs @@ -16,9 +16,17 @@ namespace Elastic.Changelog.Uploading; -public enum ArtifactType { Changelog, Bundle } +public enum ArtifactType +{ + Changelog, + Bundle +} -public enum UploadTargetKind { S3, Elasticsearch } +public enum UploadTargetKind +{ + S3, + Elasticsearch +} public record ChangelogUploadArguments { @@ -108,7 +116,12 @@ public async Task Upload(IDiagnosticsCollector collector, ChangelogUploadA return true; } - _logger.LogInformation("Found {Count} {ArtifactType} upload target(s) from {Directory}", targets.Count, args.ArtifactType, directory); + _logger.LogInformation( + "Found {Count} {ArtifactType} upload target(s) from {Directory}", + targets.Count, + args.ArtifactType, + directory + ); using var defaultClient = s3Client == null ? new AmazonS3Client() : null; var client = s3Client ?? defaultClient!; @@ -116,7 +129,12 @@ public async Task Upload(IDiagnosticsCollector collector, ChangelogUploadA var uploader = new S3IncrementalUploader(logFactory, client, _fileSystem, etagCalculator, args.S3BucketName); var result = await uploader.Upload(targets, args.SkipEtagCheck, ctx); - _logger.LogInformation("Upload complete: {Uploaded} uploaded, {Skipped} skipped, {Failed} failed", result.Uploaded, result.Skipped, result.Failed); + _logger.LogInformation( + "Upload complete: {Uploaded} uploaded, {Skipped} skipped, {Failed} failed", + result.Uploaded, + result.Skipped, + result.Failed + ); if (result.Failed > 0) collector.EmitError(string.Empty, $"{result.Failed} file(s) failed to upload"); @@ -127,38 +145,51 @@ public async Task Upload(IDiagnosticsCollector collector, ChangelogUploadA return result.Failed == 0; } - internal IReadOnlyList DiscoverUploadTargets(IDiagnosticsCollector collector, string changelogDir, string? org, string? repo, string? branch) + internal IReadOnlyList DiscoverUploadTargets( + IDiagnosticsCollector collector, + string changelogDir, + string? org, + string? repo, + string? branch + ) { // Option AD: entries live once, under the authoring org/repo/branch pool — independent of which // products later consume them. Org, repo, and branch must all resolve (CLI flags > bundle config > // git); a missing/invalid value is fatal because every entry key derives from them. if (!ChangelogKeys.IsValidOrg(org)) { - collector.EmitError(string.Empty, + collector.EmitError( + string.Empty, $"A valid GitHub owner is required to upload changelog entries (resolved: \"{org ?? ""}\"). " + - "Set --owner, bundle.owner in changelog.yml, or run inside a checkout with a github.com origin remote."); + "Set --owner, bundle.owner in changelog.yml, or run inside a checkout with a github.com origin remote." + ); return []; } if (!ChangelogKeys.IsValidRepo(repo)) { - collector.EmitError(string.Empty, + collector.EmitError( + string.Empty, $"A valid repository identifier is required to upload changelog entries (resolved: \"{repo ?? ""}\"). " + - "Set --repo, bundle.repo in changelog.yml, or run inside a checkout with a github.com origin remote."); + "Set --repo, bundle.repo in changelog.yml, or run inside a checkout with a github.com origin remote." + ); return []; } if (!ChangelogKeys.IsValidBranch(branch)) { - collector.EmitError(string.Empty, + collector.EmitError( + string.Empty, $"A valid branch is required to upload changelog entries (resolved: \"{branch ?? ""}\"). " + - "Set --branch or run inside a checkout with a current branch."); + "Set --branch or run inside a checkout with a current branch." + ); return []; } var rootDir = _fileSystem.DirectoryInfo.New(changelogDir); - var yamlFiles = _fileSystem.Directory.GetFiles(changelogDir, "*.yaml", SearchOption.TopDirectoryOnly) + var yamlFiles = _fileSystem.Directory + .GetFiles(changelogDir, "*.yaml", SearchOption.TopDirectoryOnly) .Concat(_fileSystem.Directory.GetFiles(changelogDir, "*.yml", SearchOption.TopDirectoryOnly)) .ToList(); @@ -185,7 +216,8 @@ internal IReadOnlyList DiscoverBundleUploadTargets(IDiagnosticsCol { var rootDir = _fileSystem.DirectoryInfo.New(bundleDir); - var yamlFiles = _fileSystem.Directory.GetFiles(bundleDir, "*.yaml", SearchOption.TopDirectoryOnly) + var yamlFiles = _fileSystem.Directory + .GetFiles(bundleDir, "*.yaml", SearchOption.TopDirectoryOnly) .Concat(_fileSystem.Directory.GetFiles(bundleDir, "*.yml", SearchOption.TopDirectoryOnly)) .ToList(); @@ -209,9 +241,11 @@ internal IReadOnlyList DiscoverBundleUploadTargets(IDiagnosticsCol products = ReadProductsFromParentBundle(filePath); if (products.Count == 0) { - collector.EmitWarning(filePath, + collector.EmitWarning( + filePath, "Amend bundle declares no products and its parent bundle is missing or has none; " + - "skipping upload. Re-create the amend with a current docs-builder so it carries the parent's products."); + "skipping upload. Re-create the amend with a current docs-builder so it carries the parent's products." + ); continue; } } @@ -243,9 +277,7 @@ internal IReadOnlyList DiscoverBundleUploadTargets(IDiagnosticsCol private List ReadProductsFromParentBundle(string amendFilePath) { var parentPath = BundleAmendMerger.GetParentBundlePath(amendFilePath); - return parentPath != null && _fileSystem.File.Exists(parentPath) - ? ReadProductsFromBundle(parentPath) - : []; + return parentPath != null && _fileSystem.File.Exists(parentPath) ? ReadProductsFromBundle(parentPath) : []; } private List ReadProductsFromBundle(string filePath) @@ -255,11 +287,7 @@ private List ReadProductsFromBundle(string filePath) var content = _fileSystem.File.ReadAllText(filePath); var bundle = ReleaseNotesSerialization.DeserializeBundle(content); - return bundle.Products - .Select(p => p.ProductId) - .Where(p => !string.IsNullOrWhiteSpace(p)) - .Distinct() - .ToList(); + return bundle.Products.Select(p => p.ProductId).Where(p => !string.IsNullOrWhiteSpace(p)).Distinct().ToList(); } catch (Exception ex) { diff --git a/src/services/Elastic.Changelog/Uploading/Registry.cs b/src/services/Elastic.Changelog/Uploading/Registry.cs index 17d16f3ee3..0090dc350d 100644 --- a/src/services/Elastic.Changelog/Uploading/Registry.cs +++ b/src/services/Elastic.Changelog/Uploading/Registry.cs @@ -92,11 +92,7 @@ public sealed record RegistryBundle public required string ETag { get; init; } } -[JsonSourceGenerationOptions( - WriteIndented = true, - PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull -)] +[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(Registry))] [JsonSerializable(typeof(RegistryBundle))] public sealed partial class RegistryJsonContext : JsonSerializerContext; diff --git a/src/services/Elastic.Changelog/Utilities/ChangelogUtf8Normalization.cs b/src/services/Elastic.Changelog/Utilities/ChangelogUtf8Normalization.cs index d844cfe05c..f8a75dc1ee 100644 --- a/src/services/Elastic.Changelog/Utilities/ChangelogUtf8Normalization.cs +++ b/src/services/Elastic.Changelog/Utilities/ChangelogUtf8Normalization.cs @@ -30,14 +30,12 @@ public static class ChangelogUtf8Normalization /// /// The text to normalize /// Text with leading BOM character removed if it was present - public static string StripLeadingUtf8BomChar(string text) => - Utf8TextNormalization.StripLeadingUtf8Bom(text)!; + public static string StripLeadingUtf8BomChar(string text) => Utf8TextNormalization.StripLeadingUtf8Bom(text)!; /// /// Checks if a byte span starts with the UTF-8 BOM sequence (EF BB BF). /// /// The byte span to check /// True if the span starts with UTF-8 BOM bytes - public static bool HasUtf8Bom(ReadOnlySpan bytes) => - Utf8TextNormalization.HasUtf8Bom(bytes); + public static bool HasUtf8Bom(ReadOnlySpan bytes) => Utf8TextNormalization.HasUtf8Bom(bytes); } diff --git a/src/services/Elastic.Documentation.Assembler/AssembleSources.cs b/src/services/Elastic.Documentation.Assembler/AssembleSources.cs index 5a54ed70cf..082df87a82 100644 --- a/src/services/Elastic.Documentation.Assembler/AssembleSources.cs +++ b/src/services/Elastic.Documentation.Assembler/AssembleSources.cs @@ -72,7 +72,8 @@ Cancel ctx availableExporters ); - var declaredProducts = sources.AssembleSets.Values + var declaredProducts = sources.AssembleSets + .Values .SelectMany(s => s.BuildContext.Configuration.ReleaseNotesProducts) .Distinct(StringComparer.Ordinal) .ToArray(); @@ -93,9 +94,8 @@ Cancel ctx return sources; } - internal static AssembleSources ForTests( - AssembleContext context, - FrozenDictionary assembleSets) => new(context, assembleSets); + internal static AssembleSources ForTests(AssembleContext context, FrozenDictionary assembleSets) => + new(context, assembleSets); private AssembleSources(AssembleContext context, FrozenDictionary assembleSets) { @@ -125,11 +125,22 @@ IReadOnlySet availableExporters UriResolver = uriResolver; CrossLinkResolver = crossLinkResolver; AssembleContext = assembleContext; - AssembleSets = checkouts - .Where(c => c.Repository is { Skip: false }) - .Select(c => new AssemblerDocumentationSet(logFactory, assembleContext, c, crossLinkResolver, releaseNotesResolver, configurationContext, availableExporters)) - .ToDictionary(s => s.Checkout.Repository.Name, s => s) - .ToFrozenDictionary(); + AssembleSets = + checkouts.Where(c => c.Repository is { Skip: false }) + .Select( + c => + new AssemblerDocumentationSet( + logFactory, + assembleContext, + c, + crossLinkResolver, + releaseNotesResolver, + configurationContext, + availableExporters + ) + ) + .ToDictionary(s => s.Checkout.Repository.Name, s => s) + .ToFrozenDictionary(); } public static FrozenDictionary GetTocMappings(AssembleContext context) @@ -189,6 +200,7 @@ static void ReadBlock( string? parent, int depth, int order, //TODO Remove this parameter + Uri? topLevelSource, Uri? parentSource ) @@ -257,17 +269,16 @@ static void ReadBlock( var sourcePrefix = $"{sourceUri.Host}/{sourceUri.AbsolutePath.TrimStart('/')}"; if (string.IsNullOrEmpty(pathPrefix)) - reader.EmitError($"Path prefix is not defined for: {source}, falling back to {sourcePrefix} which may be incorrect", tocEntry); + reader.EmitError( + $"Path prefix is not defined for: {source}, falling back to {sourcePrefix} which may be incorrect", + tocEntry + ); pathPrefix ??= sourcePrefix; topLevelSource ??= sourceUri; parentSource ??= sourceUri; - var tocTopLevelMapping = new NavigationTocMapping - { - Source = sourceUri, - SourcePathPrefix = pathPrefix, - }; + var tocTopLevelMapping = new NavigationTocMapping { Source = sourceUri, SourcePathPrefix = pathPrefix, }; entries.Add(new KeyValuePair(sourceUri, tocTopLevelMapping)); foreach (var entry in tocEntry.Children) diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs index 5e4b1544f0..e96208dac3 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs @@ -67,7 +67,9 @@ Cancel ctx // --assume-build is not allowed on CI: it could serve stale content from a previous/cached build // CI builds must always produce fresh, reproducible output if (assumeBuild.GetValueOrDefault(false) && _env.IsRunningOnCI) - throw new InvalidOperationException("The --assume-build flag is not allowed on CI. CI builds must always produce fresh output to ensure reproducibility and prevent stale content."); + throw new InvalidOperationException( + "The --assume-build flag is not allowed on CI. CI builds must always produce fresh output to ensure reproducibility and prevent stale content." + ); // Early return if --assume-build is specified and output already exists if (assumeBuild.GetValueOrDefault(false)) @@ -75,7 +77,10 @@ Cancel ctx var indexHtmlPath = Path.Join(assembleContext.OutputDirectory.FullName, "docs", "index.html"); if (assembleContext.OutputDirectory.Exists && fileSystem.File.Exists(indexHtmlPath)) { - _logger.LogInformation("Assuming build already exists (--assume-build). Found index.html at {Path}. Skipping build.", indexHtmlPath); + _logger.LogInformation( + "Assuming build already exists (--assume-build). Found index.html at {Path}. Skipping build.", + indexHtmlPath + ); return true; } _logger.LogInformation("--assume-build specified but output directory does not exist or is incomplete. Proceeding with build."); @@ -103,23 +108,37 @@ Cancel ctx throw new Exception("No checkouts found"); _logger.LogInformation("Preparing all assemble sources for build"); - var assembleSources = await AssembleSources.AssembleAsync(logFactory, assembleContext, checkouts, configurationContext, exporters, ctx); + var assembleSources = + await AssembleSources.AssembleAsync(logFactory, assembleContext, checkouts, configurationContext, exporters, ctx); var navigationFileInfo = configurationContext.ConfigurationFileProvider.NavigationFile; var siteNavigationFile = SiteNavigationFile.Deserialize(await fileSystem.File.ReadAllTextAsync(navigationFileInfo.FullName, ctx)); var documentationSets = assembleSources.AssembleSets.Values.Select(s => s.DocumentationSet.Navigation).ToArray(); var navigationPreviewEnabled = assembleContext.Environment.ToFeatureFlags().NavigationPreviewEnabled; - var navigation = new SiteNavigation(siteNavigationFile, assembleContext, documentationSets, assembleContext.Environment.PathPrefix, navigationPreviewEnabled); + var navigation = new SiteNavigation( + siteNavigationFile, + assembleContext, + documentationSets, + assembleContext.Environment.PathPrefix, + navigationPreviewEnabled + ); _logger.LogInformation("Validating navigation.yml does not contain colliding path prefixes"); // this validates all path prefixes are unique, early exit if duplicates are detected - if (!SiteNavigationFile.ValidatePathPrefixes(assembleContext.Collector, siteNavigationFile, navigationFileInfo) || assembleContext.Collector.Errors > 0) + if ( + !SiteNavigationFile.ValidatePathPrefixes(assembleContext.Collector, siteNavigationFile, navigationFileInfo) || + assembleContext.Collector.Errors > 0 + ) return false; var pathProvider = new GlobalNavigationPathProvider(navigation, assembleSources, assembleContext); var htmlWriter = new GlobalNavigationHtmlWriter(logFactory, navigation, collector); var legacyPageChecker = new LegacyPageService(logFactory); - var historyMapper = new PageLegacyUrlMapper(legacyPageChecker, assembleContext.VersionsConfiguration, assembleSources.LegacyUrlMappings); + var historyMapper = new PageLegacyUrlMapper( + legacyPageChecker, + assembleContext.VersionsConfiguration, + assembleSources.LegacyUrlMappings + ); var builder = new AssemblerBuilder(logFactory, assembleContext, navigation, htmlWriter, pathProvider, historyMapper); @@ -142,25 +161,26 @@ Cancel ctx // Build-time sitemap uses current date as placeholder for backwards compatibility. // Production sitemap with correct content_last_updated dates is generated via // `assembler sitemap` after ES indexing, which overwrites this file. - var urls = navigation.NavigationItems - .SelectMany(SitemapNavigationHelper.Flatten) - .Select(n => n.Url) - .Distinct(); + var urls = navigation.NavigationItems.SelectMany(SitemapNavigationHelper.Flatten).Select(n => n.Url).Distinct(); var now = DateTimeOffset.UtcNow; var entries = urls.ToDictionary(u => u, _ => now); if (entries.Count >= SitemapBuilder.WarningEntryThreshold) collector.EmitGlobalWarning( $"Sitemap has {entries.Count:N0} entries, approaching the {SitemapBuilder.MaxEntries:N0} URL protocol limit. " + - "Consider implementing sitemap index files." + "Consider implementing sitemap index files." ); - var sitemapResult = SitemapBuilder.Generate(entries, assembleContext.WriteFileSystem, assembleContext.OutputWithPathPrefixDirectory); + var sitemapResult = SitemapBuilder.Generate( + entries, + assembleContext.WriteFileSystem, + assembleContext.OutputWithPathPrefixDirectory + ); if (sitemapResult.FileSizeBytes >= SitemapBuilder.WarningFileSizeBytes) collector.EmitGlobalWarning( $"Sitemap file size is {sitemapResult.FileSizeBytes / (1024.0 * 1024.0):F1} MB, approaching the 50 MB protocol limit. " + - "Consider implementing sitemap index files." + "Consider implementing sitemap index files." ); } @@ -178,7 +198,12 @@ Cancel ctx return strict.Value ? collector.Errors + collector.Warnings == 0 : collector.Errors == 0; } - private static async Task EnhanceLlmsTxtFile(AssembleContext context, SiteNavigation navigation, LlmsNavigationEnhancer enhancer, Cancel ctx) + private static async Task EnhanceLlmsTxtFile( + AssembleContext context, + SiteNavigation navigation, + LlmsNavigationEnhancer enhancer, + Cancel ctx + ) { var pathPrefixedOutputFolder = context.OutputWithPathPrefixDirectory; var llmsTxtPath = context.ReadFileSystem.Path.Join(pathPrefixedOutputFolder.FullName, "llms.txt"); diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuilder.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuilder.cs index 4a7c0d61db..6a172c79dc 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuilder.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuilder.cs @@ -38,7 +38,11 @@ public class AssemblerBuilder( private ILegacyUrlMapper? LegacyUrlMapper { get; } = legacyUrlMapper; - public async Task BuildAllAsync(FrozenDictionary assembleSets, IReadOnlySet exportOptions, Cancel ctx) + public async Task BuildAllAsync( + FrozenDictionary assembleSets, + IReadOnlySet exportOptions, + Cancel ctx + ) { if (context.OutputDirectory.Exists) context.OutputDirectory.Delete(true); @@ -141,28 +145,43 @@ string Resolve(string path) { Uri? uri; if (Uri.IsWellFormedUriString(path, UriKind.Absolute)) // Cross-repo links + { - _ = linkResolver.TryResolve( - specificErrorMessage => context.Collector.EmitError(path, $"An error occurred while resolving cross-link {path}", specificErrorMessage), - new Uri(path), - out uri); + _ = + linkResolver.TryResolve( + specificErrorMessage => + context.Collector.EmitError(path, $"An error occurred while resolving cross-link {path}", specificErrorMessage), + new Uri(path), + out uri + ); } else // Relative links + { - uri = linkResolver.UriResolver.Resolve(new Uri($"{repository}://{path}"), - PublishEnvironmentUriResolver.MarkdownPathToUrlPath(path)); + uri = + linkResolver.UriResolver.Resolve( + new Uri($"{repository}://{path}"), + PublishEnvironmentUriResolver.MarkdownPathToUrlPath(path) + ); } return uri?.AbsolutePath ?? string.Empty; } } - private async Task BuildAsync(AssemblerDocumentationSet set, IMarkdownExporter[]? markdownExporters, IDocumentInferrerService documentInferrer, Cancel ctx) + private async Task BuildAsync( + AssemblerDocumentationSet set, + IMarkdownExporter[]? markdownExporters, + IDocumentInferrerService documentInferrer, + Cancel ctx + ) { SetFeatureFlags(set); var generator = new DocumentationGenerator( set.DocumentationSet, - logFactory, NavigationTraversable, HtmlWriter, + logFactory, + NavigationTraversable, + HtmlWriter, pathProvider, legacyUrlMapper: LegacyUrlMapper, markdownExporters: markdownExporters, @@ -177,7 +196,11 @@ private void SetFeatureFlags(AssemblerDocumentationSet set) set.DocumentationSet.Configuration.Features.PrimaryNavEnabled = true; foreach (var configurationFeatureFlag in set.AssembleContext.Environment.FeatureFlags) { - _logger.LogInformation("Setting feature flag: {ConfigurationFeatureFlagKey}={ConfigurationFeatureFlagValue}", configurationFeatureFlag.Key, configurationFeatureFlag.Value); + _logger.LogInformation( + "Setting feature flag: {ConfigurationFeatureFlagKey}={ConfigurationFeatureFlagValue}", + configurationFeatureFlag.Key, + configurationFeatureFlag.Value + ); set.DocumentationSet.Configuration.Features.Set(configurationFeatureFlag.Key, configurationFeatureFlag.Value); } set.DocumentationSet.Configuration.Features.WebsiteSearchScriptUrl = set.AssembleContext.Environment.WebsiteSearchScriptUrl; @@ -197,7 +220,9 @@ private void LogBuildTimes(List<(string Name, int FileCount, TimeSpan Duration)> var omittedCount = sortedTimes.Count - significantBuilds.Count; var maxNameLength = significantBuilds.Count > 0 ? significantBuilds.Max(x => x.Name.Length) : 0; - var maxFileCountLength = significantBuilds.Count > 0 ? significantBuilds.Max(x => x.FileCount.ToString(CultureInfo.InvariantCulture).Length) : 0; + var maxFileCountLength = significantBuilds.Count > 0 + ? significantBuilds.Max(x => x.FileCount.ToString(CultureInfo.InvariantCulture).Length) + : 0; _logger.LogInformation("Build times (descending):"); foreach (var (name, fileCount, duration) in significantBuilds) @@ -207,20 +232,34 @@ private void LogBuildTimes(List<(string Name, int FileCount, TimeSpan Duration)> var paddedFiles = fileCount.ToString(CultureInfo.InvariantCulture).PadLeft(maxFileCountLength); var paddedName = name.PadRight(maxNameLength); var paddedMsPerFile = msPerFile.ToString("F2", CultureInfo.InvariantCulture).PadLeft(5); - _logger.LogInformation(" {Time} {Files} files {MsPerFile} ms/file {Name}", paddedTime, paddedFiles, paddedMsPerFile, paddedName); + _logger.LogInformation( + " {Time} {Files} files {MsPerFile} ms/file {Name}", + paddedTime, + paddedFiles, + paddedMsPerFile, + paddedName + ); } if (omittedCount > 0) - _logger.LogInformation(" ... omitted {OmittedCount} repositories with insignificant contribution to build times", omittedCount); + _logger.LogInformation( + " ... omitted {OmittedCount} repositories with insignificant contribution to build times", + omittedCount + ); - _logger.LogInformation("Total: {TotalDuration:mm\\:ss\\.fff} {TotalFiles} files {AvgMsPerFile:F2} ms/file", totalDuration, totalFiles, avgMsPerFile); + _logger.LogInformation( + "Total: {TotalDuration:mm\\:ss\\.fff} {TotalFiles} files {AvgMsPerFile:F2} ms/file", + totalDuration, + totalFiles, + avgMsPerFile + ); } private async Task OutputRedirectsAsync(Dictionary redirects, Cancel ctx) { - var uniqueRedirects = redirects - .Where(x => !x.Key.TrimEnd('/').Equals(x.Value.TrimEnd('/'), StringComparison.OrdinalIgnoreCase)) - .ToDictionary(); + var uniqueRedirects = redirects.Where( + x => !x.Key.TrimEnd('/').Equals(x.Value.TrimEnd('/'), StringComparison.OrdinalIgnoreCase) + ).ToDictionary(); var redirectsFile = context.WriteFileSystem.FileInfo.New(Path.Join(context.OutputDirectory.FullName, "redirects.json")); _logger.LogInformation("Writing {Count} resolved redirects to {Path}", uniqueRedirects.Count, redirectsFile.FullName); diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerOpenApiBuildStep.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerOpenApiBuildStep.cs index 5908c4b4aa..433e14da60 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerOpenApiBuildStep.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerOpenApiBuildStep.cs @@ -25,7 +25,8 @@ public static async Task BuildAsync( ILoggerFactory logFactory, AssembleContext assembleContext, AssembleSources assembleSources, - Cancel ctx) + Cancel ctx + ) { var logger = logFactory.CreateLogger(typeof(AssemblerOpenApiBuildStep)); var env = assembleContext.Environment; @@ -58,7 +59,8 @@ public static async Task BuildAsync( logFactory, owner.Set.BuildContext, generator.MarkdownStringRenderer, - versionIndexClient); + versionIndexClient + ); var entries = await openApiGenerator.GenerateProducts(ctx).ConfigureAwait(false); catalogEntries.AddRange(entries); } @@ -70,7 +72,8 @@ public static async Task BuildAsync( logFactory, catalogContext, new DocumentationGenerator(owners[0].Set.DocumentationSet, logFactory).MarkdownStringRenderer, - versionIndexClient); + versionIndexClient + ); await catalogGenerator.GenerateCatalog(catalogEntries, ctx).ConfigureAwait(false); } @@ -78,12 +81,14 @@ public static async Task BuildAsync( logger.LogInformation( "Finished generating OpenAPI pages under {OutputDirectory} in {DurationMs} ms", assembleContext.OutputWithPathPrefixDirectory.FullName, - stopwatch.ElapsedMilliseconds); + stopwatch.ElapsedMilliseconds + ); } internal static IReadOnlyList DiscoverApiOwners( FrozenDictionary assembleSets, - IDiagnosticsCollector collector) + IDiagnosticsCollector collector + ) { var keyOwners = new Dictionary(StringComparer.OrdinalIgnoreCase); var owners = new List(); @@ -99,7 +104,8 @@ internal static IReadOnlyList DiscoverApiOwners( if (keyOwners.TryGetValue(apiKey, out var existingRepository)) { collector.EmitGlobalError( - $"Duplicate API key '{apiKey}' declared in {existingRepository} and {set.Checkout.Repository.Name}"); + $"Duplicate API key '{apiKey}' declared in {existingRepository} and {set.Checkout.Repository.Name}" + ); continue; } @@ -112,9 +118,7 @@ internal static IReadOnlyList DiscoverApiOwners( return owners; } - private static void ApplyFeatureFlags( - AssemblerDocumentationSet set, - IReadOnlyDictionary featureFlags) + private static void ApplyFeatureFlags(AssemblerDocumentationSet set, IReadOnlyDictionary featureFlags) { foreach (var (key, value) in featureFlags) set.BuildContext.Configuration.Features.Set(key, value); diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerSitemapService.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerSitemapService.cs index 7542c7bfd5..556a407d84 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerSitemapService.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerSitemapService.cs @@ -71,7 +71,7 @@ public async Task GenerateSitemapAsync( if (entries.Count >= SitemapBuilder.WarningEntryThreshold) collector.EmitGlobalWarning( $"Sitemap has {entries.Count:N0} entries, approaching the {SitemapBuilder.MaxEntries:N0} URL protocol limit. " + - "Consider implementing sitemap index files." + "Consider implementing sitemap index files." ); var result = SitemapBuilder.Generate(entries, assembleContext.WriteFileSystem, assembleContext.OutputWithPathPrefixDirectory); @@ -79,7 +79,7 @@ public async Task GenerateSitemapAsync( if (result.FileSizeBytes >= SitemapBuilder.WarningFileSizeBytes) collector.EmitGlobalWarning( $"Sitemap file size is {result.FileSizeBytes / (1024.0 * 1024.0):F1} MB, approaching the 50 MB protocol limit. " + - "Consider implementing sitemap index files." + "Consider implementing sitemap index files." ); _logger.LogInformation("Sitemap written to {Path}", assembleContext.OutputWithPathPrefixDirectory.FullName); diff --git a/src/services/Elastic.Documentation.Assembler/Building/EsSitemapReader.cs b/src/services/Elastic.Documentation.Assembler/Building/EsSitemapReader.cs index 229dab4e9e..98e5dc8c13 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/EsSitemapReader.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/EsSitemapReader.cs @@ -35,7 +35,8 @@ public async IAsyncEnumerable ReadAllAsync([EnumeratorCancellation if (!response.ApiCallDetails.HasSuccessfulStatusCode) throw new InvalidOperationException( - $"ES search failed (page {page}): {response.ApiCallDetails.HttpStatusCode} {response.ApiCallDetails.DebugInformation}"); + $"ES search failed (page {page}): {response.ApiCallDetails.HttpStatusCode} {response.ApiCallDetails.DebugInformation}" + ); var root = response.Body; @@ -66,7 +67,9 @@ public async IAsyncEnumerable ReadAllAsync([EnumeratorCancellation if (url is null || lastUpdatedStr is null) continue; - if (!DateTimeOffset.TryParse(lastUpdatedStr, CultureInfo.InvariantCulture, DateTimeStyles.None, out var lastUpdated)) + if ( + !DateTimeOffset.TryParse(lastUpdatedStr, CultureInfo.InvariantCulture, DateTimeStyles.None, out var lastUpdated) + ) { logger.LogWarning("Sitemap: skipping {Url}, unparseable content_last_updated: {Value}", url, lastUpdatedStr); continue; @@ -78,8 +81,8 @@ public async IAsyncEnumerable ReadAllAsync([EnumeratorCancellation page++; logger.LogInformation("Sitemap: fetched page {Page} ({Hits} hits)", page, hitCount); - - } while (hitCount == PageSize); + } + while (hitCount == PageSize); } finally { @@ -90,12 +93,12 @@ public async IAsyncEnumerable ReadAllAsync([EnumeratorCancellation private async Task OpenPitAsync(Cancel ct) { - var response = await transport.PostAsync( - $"/{indexName}/_pit?keep_alive={PitKeepAlive}", PostData.Empty, ct); + var response = await transport.PostAsync($"/{indexName}/_pit?keep_alive={PitKeepAlive}", PostData.Empty, ct); if (!response.ApiCallDetails.HasSuccessfulStatusCode) throw new InvalidOperationException( - $"Failed to open PIT on {indexName}: {response.ApiCallDetails.HttpStatusCode} {response.ApiCallDetails.DebugInformation}"); + $"Failed to open PIT on {indexName}: {response.ApiCallDetails.HttpStatusCode} {response.ApiCallDetails.DebugInformation}" + ); var pitId = response.Body.Get("id"); if (string.IsNullOrEmpty(pitId)) @@ -136,17 +139,10 @@ internal static string BuildSearchBody(string pitId, string[]? searchAfter) { ["bool"] = new JsonObject { - ["must_not"] = new JsonArray(new JsonObject - { - ["term"] = new JsonObject { ["hidden"] = true } - }) + ["must_not"] = new JsonArray(new JsonObject { ["term"] = new JsonObject { ["hidden"] = true } }) } }, - ["pit"] = new JsonObject - { - ["id"] = pitId, - ["keep_alive"] = PitKeepAlive - }, + ["pit"] = new JsonObject { ["id"] = pitId, ["keep_alive"] = PitKeepAlive }, ["sort"] = new JsonArray(new JsonObject { ["url"] = "asc" }) }; diff --git a/src/services/Elastic.Documentation.Assembler/Building/ExporterParser.cs b/src/services/Elastic.Documentation.Assembler/Building/ExporterParser.cs index a2155af2f6..2e5bf0ef11 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/ExporterParser.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/ExporterParser.cs @@ -71,7 +71,8 @@ public bool TryParse(string raw, out IReadOnlySet result) break; default: throw new ArgumentException( - $"Unknown exporter '{token}'. Valid values: html, llm, es, config, links, state, redirects, okf, pagefind, default, metadata, none."); + $"Unknown exporter '{token}'. Valid values: html, llm, es, config, links, state, redirects, okf, pagefind, default, metadata, none." + ); } } result = set; diff --git a/src/services/Elastic.Documentation.Assembler/Building/SitemapBuilder.cs b/src/services/Elastic.Documentation.Assembler/Building/SitemapBuilder.cs index 06130da5be..3ba1afd7a1 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/SitemapBuilder.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/SitemapBuilder.cs @@ -32,32 +32,31 @@ IDirectoryInfo outputFolder { // API pages are generated only on staging (assembler-api-explorer flag) and /docs/api/* is still // proxied to bump.sh at the edge (#725). Keep them out of the sitemap until cutover. - var filtered = entries - .Where(e => !e.Key.StartsWith("/docs/api/", StringComparison.Ordinal)) - .ToList(); + var filtered = entries.Where(e => !e.Key.StartsWith("/docs/api/", StringComparison.Ordinal)).ToList(); if (filtered.Count > MaxEntries) throw new InvalidOperationException( $"Sitemap contains {filtered.Count:N0} URLs, which exceeds the sitemap protocol limit of {MaxEntries:N0}. " + - "Consider implementing sitemap index files to split entries across multiple sitemaps." + "Consider implementing sitemap index files to split entries across multiple sitemaps." ); - var doc = new XDocument - { - Declaration = new XDeclaration("1.0", "utf-8", "yes") - }; + var doc = new XDocument { Declaration = new XDeclaration("1.0", "utf-8", "yes") }; XNamespace ns = "http://www.sitemaps.org/schemas/sitemap/0.9"; var root = new XElement( ns + "urlset", new XAttribute("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9"), - filtered - .OrderBy(e => e.Key, StringComparer.Ordinal) - .Select(e => new XElement(ns + "url", [ - new XElement(ns + "loc", new Uri(BaseUri, e.Key)), - new XElement(ns + "lastmod", e.Value.ToString("o", CultureInfo.InvariantCulture)) - ])) + filtered.OrderBy(e => e.Key, StringComparer.Ordinal).Select( + e => + new XElement( + ns + "url", + [ + new XElement(ns + "loc", new Uri(BaseUri, e.Key)), + new XElement(ns + "lastmod", e.Value.ToString("o", CultureInfo.InvariantCulture)) + ] + ) + ) ); doc.Add(root); @@ -69,7 +68,7 @@ IDirectoryInfo outputFolder if (fileSize > MaxFileSizeBytes) throw new InvalidOperationException( $"Sitemap file size is {fileSize / (1024.0 * 1024.0):F1} MB, which exceeds the sitemap protocol limit of 50 MB. " + - "Consider implementing sitemap index files to split entries across multiple sitemaps." + "Consider implementing sitemap index files to split entries across multiple sitemaps." ); if (!outputFolder.Exists) @@ -87,16 +86,14 @@ IDirectoryInfo outputFolder /// Extracts URLs from navigation items for sitemap generation. public static class SitemapNavigationHelper { - public static IEnumerable Flatten(INavigationItem item) => - item switch - { - ILeafNavigationItem => [], - ILeafNavigationItem => [], - ILeafNavigationItem { Hidden: true } => [], - ILeafNavigationItem file => [file], - INodeNavigationItem { Hidden: true } => [], - INodeNavigationItem group => - group.NavigationItems.SelectMany(Flatten).Append(group), - _ => [] - }; + public static IEnumerable Flatten(INavigationItem item) => item switch + { + ILeafNavigationItem => [], + ILeafNavigationItem => [], + ILeafNavigationItem { Hidden: true } => [], + ILeafNavigationItem file => [file], + INodeNavigationItem { Hidden: true } => [], + INodeNavigationItem group => group.NavigationItems.SelectMany(Flatten).Append(group), + _ => [] + }; } diff --git a/src/services/Elastic.Documentation.Assembler/Configuration/ConfigurationCloneService.cs b/src/services/Elastic.Documentation.Assembler/Configuration/ConfigurationCloneService.cs index 5dc7daf087..3c8e94dbfc 100644 --- a/src/services/Elastic.Documentation.Assembler/Configuration/ConfigurationCloneService.cs +++ b/src/services/Elastic.Documentation.Assembler/Configuration/ConfigurationCloneService.cs @@ -12,20 +12,11 @@ namespace Elastic.Documentation.Assembler.Configuration; -public class ConfigurationCloneService( - ILoggerFactory logFactory, - AssemblyConfiguration assemblyConfiguration, - IFileSystem fs -) : IService +public class ConfigurationCloneService(ILoggerFactory logFactory, AssemblyConfiguration assemblyConfiguration, IFileSystem fs) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - public async Task InitConfigurationToApplicationData( - IDiagnosticsCollector collector, - string? gitRef, - bool saveLocal, - Cancel ctx - ) + public async Task InitConfigurationToApplicationData(IDiagnosticsCollector collector, string? gitRef, bool saveLocal, Cancel ctx) { var checkoutFolder = fs.DirectoryInfo.New(ConfigurationFileProvider.AppDataConfigurationDirectory).Parent; if (saveLocal) @@ -46,17 +37,18 @@ Cancel ctx // relies on the embedded configuration, but we don't expect this to change var repository = assemblyConfiguration.ReferenceRepositories["docs-builder"]; - repository = repository with - { - SparsePaths = ["config"] - }; + repository = repository with { SparsePaths = ["config"] }; var gitReference = gitRef; if (string.IsNullOrEmpty(gitReference)) gitReference = "main"; _logger.LogInformation("Cloning configuration ({GitReference})", gitReference); var checkout = cloner.CloneRef(repository, gitReference, appendRepositoryName: false); - _logger.LogInformation("Cloned configuration ({GitReference}) to {ConfigurationFolder}", checkout.HeadReference, checkout.Directory.FullName); + _logger.LogInformation( + "Cloned configuration ({GitReference}) to {ConfigurationFolder}", + checkout.HeadReference, + checkout.Directory.FullName + ); if (gitRef is not null && !checkout.HeadReference.StartsWith(gitRef, StringComparison.OrdinalIgnoreCase)) { diff --git a/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryBuildMatchingService.cs b/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryBuildMatchingService.cs index 300e51a061..74cc408369 100644 --- a/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryBuildMatchingService.cs +++ b/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryBuildMatchingService.cs @@ -37,8 +37,13 @@ private async Task GetRegistryWithRetry(Aws3LinkIndexReader provid catch (Exception ex) when (attempt < maxAttempts) { var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); - _logger.LogWarning("S3 link registry fetch failed (attempt {Attempt}/{Max}), retrying in {Delay}s: {Message}", - attempt, maxAttempts, delay.TotalSeconds, ex.Message); + _logger.LogWarning( + "S3 link registry fetch failed (attempt {Attempt}/{Max}), retrying in {Delay}s: {Message}", + attempt, + maxAttempts, + delay.TotalSeconds, + ex.Message + ); await Task.Delay(delay, ctx); } } @@ -70,7 +75,12 @@ public async Task ShouldBuild(IDiagnosticsCollector collector, string? rep var linkIndexProvider = Aws3LinkIndexReader.CreateAnonymous(); var linkRegistry = await GetRegistryWithRetry(linkIndexProvider, ctx); var alreadyPublishing = linkRegistry.Repositories.ContainsKey(repositoryName); - _logger.LogInformation("'{Repository}' (registry key: '{RepositoryName}') publishing to link registry: {PublishState} ", repo, repositoryName, alreadyPublishing); + _logger.LogInformation( + "'{Repository}' (registry key: '{RepositoryName}') publishing to link registry: {PublishState} ", + repo, + repositoryName, + alreadyPublishing + ); var assembleContext = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem); var product = assembleContext.ProductsConfiguration.GetProductByRepositoryName(repo); var matches = assembleContext.Configuration.Match(logFactory, repo, refName, product, alreadyPublishing); @@ -86,9 +96,19 @@ public async Task ShouldBuild(IDiagnosticsCollector collector, string? rep } if (matches.Current is { } current) - _logger.LogInformation("'{Repository}' '{BranchOrTag}' is configured as '{Matches}' content-source", repo, refName, current.ToStringFast(true)); + _logger.LogInformation( + "'{Repository}' '{BranchOrTag}' is configured as '{Matches}' content-source", + repo, + refName, + current.ToStringFast(true) + ); if (matches.Next is { } next) - _logger.LogInformation("'{Repository}' '{BranchOrTag}' is configured as '{Matches}' content-source", repo, refName, next.ToStringFast(true)); + _logger.LogInformation( + "'{Repository}' '{BranchOrTag}' is configured as '{Matches}' content-source", + repo, + refName, + next.ToStringFast(true) + ); await githubActionsService.SetOutputAsync("content-source-match", "true"); await githubActionsService.SetOutputAsync("content-source-next", matches.Next is not null ? "true" : "false"); diff --git a/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs b/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs index 45dc7bb2b7..8385ddb421 100644 --- a/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs +++ b/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs @@ -47,14 +47,18 @@ public async Task ValidatePublishStatus(IDiagnosticsCollector collector, C var next = repository.GetBranch(ContentSource.Next); if (!registryMapping.TryGetValue(next, out _)) { - collector.EmitError(reportPath, - $"'{repository.Name}' has not yet published links.json for configured 'next' content source: '{next}' see {linkIndexReader.RegistryUrl}"); + collector.EmitError( + reportPath, + $"'{repository.Name}' has not yet published links.json for configured 'next' content source: '{next}' see {linkIndexReader.RegistryUrl}" + ); } if (!registryMapping.TryGetValue(current, out _)) { - collector.EmitError(reportPath, - $"'{repository.Name}' has not yet published links.json for configured 'current' content source: '{current}' see {linkIndexReader.RegistryUrl}"); + collector.EmitError( + reportPath, + $"'{repository.Name}' has not yet published links.json for configured 'current' content source: '{current}' see {linkIndexReader.RegistryUrl}" + ); } } diff --git a/src/services/Elastic.Documentation.Assembler/Deploying/DeployUpdateRedirectsService.cs b/src/services/Elastic.Documentation.Assembler/Deploying/DeployUpdateRedirectsService.cs index 01af135702..707c146bf9 100644 --- a/src/services/Elastic.Documentation.Assembler/Deploying/DeployUpdateRedirectsService.cs +++ b/src/services/Elastic.Documentation.Assembler/Deploying/DeployUpdateRedirectsService.cs @@ -22,7 +22,8 @@ public async Task UpdateRedirects( string? redirectsFile, string kvsNamePrefix = "elastic-docs-v3", string? defaultRedirectsFile = null, - Cancel ctx = default) + Cancel ctx = default + ) { redirectsFile ??= defaultRedirectsFile ?? ".artifacts/assembly/redirects.json"; if (!fileSystem.File.Exists(redirectsFile)) @@ -42,7 +43,11 @@ public async Task UpdateRedirects( } var kvsName = $"{kvsNamePrefix}-{environment}-redirects-kvs"; - var cloudFrontClient = new AwsCloudFrontKeyValueStoreProxy(collector, logFactory, fileSystem.DirectoryInfo.New(fileSystem.Directory.GetCurrentDirectory())); + var cloudFrontClient = new AwsCloudFrontKeyValueStoreProxy( + collector, + logFactory, + fileSystem.DirectoryInfo.New(fileSystem.Directory.GetCurrentDirectory()) + ); cloudFrontClient.UpdateRedirects(kvsName, sourcedRedirects); return collector.Errors == 0; diff --git a/src/services/Elastic.Documentation.Assembler/Deploying/Redirects/AwsCloudFrontKeyValueStoreModels.cs b/src/services/Elastic.Documentation.Assembler/Deploying/Redirects/AwsCloudFrontKeyValueStoreModels.cs index 4e1ccf7337..dd767b9d88 100644 --- a/src/services/Elastic.Documentation.Assembler/Deploying/Redirects/AwsCloudFrontKeyValueStoreModels.cs +++ b/src/services/Elastic.Documentation.Assembler/Deploying/Redirects/AwsCloudFrontKeyValueStoreModels.cs @@ -6,10 +6,16 @@ namespace Elastic.Documentation.Assembler.Deploying.Redirects; -public record DescribeKeyValueStoreResponse([property: JsonPropertyName("ETag")] string ETag, [property: JsonPropertyName("KeyValueStore")] KeyValueStore KeyValueStore); +public record DescribeKeyValueStoreResponse( + [property: JsonPropertyName("ETag")] string ETag, + [property: JsonPropertyName("KeyValueStore")] KeyValueStore KeyValueStore +); public record KeyValueStore([property: JsonPropertyName("ARN")] string ARN); -public record ListKeysResponse([property: JsonPropertyName("NextToken")] string? NextToken, [property: JsonPropertyName("Items")] List Items); +public record ListKeysResponse( + [property: JsonPropertyName("NextToken")] string? NextToken, + [property: JsonPropertyName("Items")] List Items +); public record KeyItem([property: JsonPropertyName("Key")] string Key); public record UpdateKeysResponse([property: JsonPropertyName("ETag")] string ETag); diff --git a/src/services/Elastic.Documentation.Assembler/Deploying/Redirects/AwsCloudFrontKeyValueStoreProxy.cs b/src/services/Elastic.Documentation.Assembler/Deploying/Redirects/AwsCloudFrontKeyValueStoreProxy.cs index c9875a8799..d8b83c048f 100644 --- a/src/services/Elastic.Documentation.Assembler/Deploying/Redirects/AwsCloudFrontKeyValueStoreProxy.cs +++ b/src/services/Elastic.Documentation.Assembler/Deploying/Redirects/AwsCloudFrontKeyValueStoreProxy.cs @@ -16,8 +16,11 @@ internal enum KvsOperation Deletes } -public class AwsCloudFrontKeyValueStoreProxy(IDiagnosticsCollector collector, ILoggerFactory logFactory, IDirectoryInfo workingDirectory) - : ExternalCommandExecutor(collector, workingDirectory) +public class AwsCloudFrontKeyValueStoreProxy( + IDiagnosticsCollector collector, + ILoggerFactory logFactory, + IDirectoryInfo workingDirectory +) : ExternalCommandExecutor(collector, workingDirectory) { /// protected override ILogger Logger { get; } = logFactory.CreateLogger(); @@ -39,15 +42,23 @@ public void UpdateRedirects(string kvsName, IReadOnlyDictionary if (RedirectKvsDiff.WouldWipeAllExisting(sourcedRedirects, existingRedirects)) { - Collector.EmitError("", $"Refusing to update redirects: sourced redirects are empty but the KVS contains {existingRedirects.Count} entries. " + - "This would wipe every redirect. Verify the assembler produced a non-empty redirects.json before retrying."); + Collector.EmitError( + "", + $"Refusing to update redirects: sourced redirects are empty but the KVS contains {existingRedirects.Count} entries. " + + "This would wipe every redirect. Verify the assembler produced a non-empty redirects.json before retrying." + ); return; } var (toPut, toDelete) = RedirectKvsDiff.ComputeBatchUpdates(sourcedRedirects, existingRedirects); - Logger.LogInformation("Computed redirect KVS diff: {ToPut} to put, {ToDelete} to delete (from {Existing} existing, {Sourced} sourced)", - toPut.Length, toDelete.Length, existingRedirects.Count, sourcedRedirects.Count); + Logger.LogInformation( + "Computed redirect KVS diff: {ToPut} to put, {ToDelete} to delete (from {Existing} existing, {Sourced} sourced)", + toPut.Length, + toDelete.Length, + existingRedirects.Count, + sourcedRedirects.Count + ); eTag = ProcessBatchUpdates(kvsArn, eTag, toDelete, KvsOperation.Deletes); _ = ProcessBatchUpdates(kvsArn, eTag, toPut, KvsOperation.Puts); @@ -66,7 +77,10 @@ private string DescribeKeyValueStore(string kvsName) return string.Empty; } - var describeResponse = JsonSerializer.Deserialize(concatJson, AwsCloudFrontKeyValueStoreJsonContext.Default.DescribeKeyValueStoreResponse); + var describeResponse = JsonSerializer.Deserialize( + concatJson, + AwsCloudFrontKeyValueStoreJsonContext.Default.DescribeKeyValueStoreResponse + ); if (describeResponse?.KeyValueStore is { ARN.Length: > 0 }) return describeResponse.KeyValueStore.ARN; @@ -92,7 +106,10 @@ private string AcquireETag(string kvsArn) Collector.EmitError("", "The output from cloudfront-keyvaluestore:describe-key-value-store was empty"); return string.Empty; } - var describeResponse = JsonSerializer.Deserialize(concatJson, AwsCloudFrontKeyValueStoreJsonContext.Default.DescribeKeyValueStoreResponse); + var describeResponse = JsonSerializer.Deserialize( + concatJson, + AwsCloudFrontKeyValueStoreJsonContext.Default.DescribeKeyValueStoreResponse + ); if (describeResponse?.ETag is not null) return describeResponse.ETag; @@ -123,7 +140,10 @@ private bool TryListAllKeys(string kvsArn, out HashSet keys) Collector.EmitError("", "The output from cloudfront-keyvaluestore:list-keys was empty"); throw new JsonException("Empty output from cloudfront-keyvaluestore:list-keys"); } - var response = JsonSerializer.Deserialize(concatJson, AwsCloudFrontKeyValueStoreJsonContext.Default.ListKeysResponse); + var response = JsonSerializer.Deserialize( + concatJson, + AwsCloudFrontKeyValueStoreJsonContext.Default.ListKeysResponse + ); if (response?.Items != null) { @@ -132,7 +152,8 @@ private bool TryListAllKeys(string kvsArn, out HashSet keys) } nextToken = response?.NextToken; - } while (!string.IsNullOrEmpty(nextToken)); + } + while (!string.IsNullOrEmpty(nextToken)); } catch (Exception e) { @@ -142,29 +163,45 @@ private bool TryListAllKeys(string kvsArn, out HashSet keys) return true; } - - private string ProcessBatchUpdates( - string kvsArn, - string eTag, - IReadOnlyCollection items, - KvsOperation operation) + private string ProcessBatchUpdates(string kvsArn, string eTag, IReadOnlyCollection items, KvsOperation operation) { const int batchSize = 50; - Logger.LogInformation("Processing {Count} items in batches of {BatchSize} for {Operation} update operation.", items.Count, batchSize, operation); + Logger.LogInformation( + "Processing {Count} items in batches of {BatchSize} for {Operation} update operation.", + items.Count, + batchSize, + operation + ); try { foreach (var batch in items.Chunk(batchSize)) { var payload = operation switch { - KvsOperation.Puts => JsonSerializer.Serialize(batch.Cast().ToList(), - AwsCloudFrontKeyValueStoreJsonContext.Default.ListPutKeyRequestListItem), - KvsOperation.Deletes => JsonSerializer.Serialize(batch.Cast().ToList(), - AwsCloudFrontKeyValueStoreJsonContext.Default.ListDeleteKeyRequestListItem), + KvsOperation.Puts => + JsonSerializer.Serialize( + batch.Cast().ToList(), + AwsCloudFrontKeyValueStoreJsonContext.Default.ListPutKeyRequestListItem + ), + KvsOperation.Deletes => + JsonSerializer.Serialize( + batch.Cast().ToList(), + AwsCloudFrontKeyValueStoreJsonContext.Default.ListDeleteKeyRequestListItem + ), _ => string.Empty }; - var responseJson = CaptureMultiple(1, "aws", "cloudfront-keyvaluestore", "update-keys", "--kvs-arn", kvsArn, "--if-match", eTag, - $"--{operation.ToString().ToLowerInvariant()}", payload); + var responseJson = CaptureMultiple( + 1, + "aws", + "cloudfront-keyvaluestore", + "update-keys", + "--kvs-arn", + kvsArn, + "--if-match", + eTag, + $"--{operation.ToString().ToLowerInvariant()}", + payload + ); var concatJson = string.Concat(responseJson); if (string.IsNullOrWhiteSpace(concatJson)) @@ -173,7 +210,10 @@ private string ProcessBatchUpdates( throw new JsonException("Empty output from cloudfront-keyvaluestore:update-keys"); } - var updateResponse = JsonSerializer.Deserialize(concatJson, AwsCloudFrontKeyValueStoreJsonContext.Default.UpdateKeysResponse); + var updateResponse = JsonSerializer.Deserialize( + concatJson, + AwsCloudFrontKeyValueStoreJsonContext.Default.UpdateKeysResponse + ); if (string.IsNullOrEmpty(updateResponse?.ETag)) throw new Exception("Failed to get new ETag after update operation."); diff --git a/src/services/Elastic.Documentation.Assembler/Deploying/Redirects/RedirectKvsDiff.cs b/src/services/Elastic.Documentation.Assembler/Deploying/Redirects/RedirectKvsDiff.cs index eedb1727e6..93c7ff2d03 100644 --- a/src/services/Elastic.Documentation.Assembler/Deploying/Redirects/RedirectKvsDiff.cs +++ b/src/services/Elastic.Documentation.Assembler/Deploying/Redirects/RedirectKvsDiff.cs @@ -19,21 +19,17 @@ internal static class RedirectKvsDiff /// Keys currently present in the live KVS. public static (PutKeyRequestListItem[] ToPut, DeleteKeyRequestListItem[] ToDelete) ComputeBatchUpdates( IReadOnlyDictionary sourcedRedirects, - IReadOnlyCollection existingRedirects) + IReadOnlyCollection existingRedirects + ) { - var toPut = sourcedRedirects - .Select(kvp => new PutKeyRequestListItem { Key = kvp.Key, Value = kvp.Value }) - .ToArray(); + var toPut = sourcedRedirects.Select(kvp => new PutKeyRequestListItem { Key = kvp.Key, Value = kvp.Value }).ToArray(); // Stale entries = keys in KVS that no longer appear in the new sourced file. // Operand order matters: it must be `existingRedirects.Except(sourcedRedirects.Keys)`. // The reverse (sourcedRedirects.Keys.Except(existingRedirects)) computes the // brand-new keys we are about to PUT, which makes the DELETE batch a no-op and // causes stale redirects to live in the KVS forever. - var toDelete = existingRedirects - .Except(sourcedRedirects.Keys) - .Select(k => new DeleteKeyRequestListItem { Key = k }) - .ToArray(); + var toDelete = existingRedirects.Except(sourcedRedirects.Keys).Select(k => new DeleteKeyRequestListItem { Key = k }).ToArray(); return (toPut, toDelete); } @@ -45,6 +41,6 @@ public static (PutKeyRequestListItem[] ToPut, DeleteKeyRequestListItem[] ToDelet /// public static bool WouldWipeAllExisting( IReadOnlyDictionary sourcedRedirects, - IReadOnlyCollection existingRedirects) => - sourcedRedirects.Count == 0 && existingRedirects.Count > 0; + IReadOnlyCollection existingRedirects + ) => sourcedRedirects.Count == 0 && existingRedirects.Count > 0; } diff --git a/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerAiEnrichService.cs b/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerAiEnrichService.cs index 36de6f119c..e887732efa 100644 --- a/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerAiEnrichService.cs +++ b/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerAiEnrichService.cs @@ -74,7 +74,12 @@ Cancel ctx { _logger.LogInformation( "[AI enrichment] {Phase}: enriched={Enriched} failed={Failed} candidates={Candidates}{Message}", - p.Phase, p.Enriched, p.Failed, p.TotalCandidates, p.Message is not null ? $" — {p.Message}" : ""); + p.Phase, + p.Enriched, + p.Failed, + p.TotalCandidates, + p.Message is not null ? $" — {p.Message}" : "" + ); last = p; } } @@ -86,7 +91,10 @@ Cancel ctx if (last is not null) _logger.LogInformation( "AI enrichment complete: {Enriched} enriched, {Failed} failed, {Candidates} candidates", - last.Enriched, last.Failed, last.TotalCandidates); + last.Enriched, + last.Failed, + last.TotalCandidates + ); // Intentionally does not call exporter.StopAsync(): completing a zero-write incremental sync // would delete every document that wasn't re-written this run. diff --git a/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs b/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs index a77c7347a2..b7afef6c91 100644 --- a/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs +++ b/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs @@ -36,14 +36,19 @@ public async Task Index( var cfg = _configurationContext.Endpoints.Elasticsearch; await ElasticsearchEndpointConfigurator.ApplyAsync(cfg, es, collector, fileSystem, ctx); - return await BuildAll(collector, new AssemblerBuildOptions - { - Strict = false, - Environment = environment, - MetadataOnly = true, - ShowHints = false, - Exporters = new HashSet { Elasticsearch }, - AssumeBuild = false - }, fileSystem, ctx); + return await BuildAll( + collector, + new AssemblerBuildOptions + { + Strict = false, + Environment = environment, + MetadataOnly = true, + ShowHints = false, + Exporters = new HashSet { Elasticsearch }, + AssumeBuild = false + }, + fileSystem, + ctx + ); } } diff --git a/src/services/Elastic.Documentation.Assembler/Links/AssemblerCrossLinkFetcher.cs b/src/services/Elastic.Documentation.Assembler/Links/AssemblerCrossLinkFetcher.cs index 3277af1ef4..f7e79e5601 100644 --- a/src/services/Elastic.Documentation.Assembler/Links/AssemblerCrossLinkFetcher.cs +++ b/src/services/Elastic.Documentation.Assembler/Links/AssemblerCrossLinkFetcher.cs @@ -12,8 +12,12 @@ namespace Elastic.Documentation.Assembler.Links; /// fetches all the cross-links for all repositories defined in assembler.yml configuration -public class AssemblerCrossLinkFetcher(ILoggerFactory logFactory, AssemblyConfiguration configuration, PublishEnvironment publishEnvironment, ILinkIndexReader linkIndexProvider) - : CrossLinkFetcher(logFactory, linkIndexProvider) +public class AssemblerCrossLinkFetcher( + ILoggerFactory logFactory, + AssemblyConfiguration configuration, + PublishEnvironment publishEnvironment, + ILinkIndexReader linkIndexProvider +) : CrossLinkFetcher(logFactory, linkIndexProvider) { public override async Task FetchCrossLinks(Cancel ctx) { @@ -21,9 +25,7 @@ public override async Task FetchCrossLinks(Cancel ctx) // We do want to always fetch cross-link data for all repositories. // This is public information - var repositories = configuration.AvailableRepositories.Values - .Concat(configuration.PrivateRepositories.Values) - .ToList(); + var repositories = configuration.AvailableRepositories.Values.Concat(configuration.PrivateRepositories.Values).ToList(); // Deduplicate and filter skipped repos var declaredRepositories = new HashSet(); diff --git a/src/services/Elastic.Documentation.Assembler/Links/LinkUtilResults.cs b/src/services/Elastic.Documentation.Assembler/Links/LinkUtilResults.cs index 115a68ffeb..4c3103b4b7 100644 --- a/src/services/Elastic.Documentation.Assembler/Links/LinkUtilResults.cs +++ b/src/services/Elastic.Documentation.Assembler/Links/LinkUtilResults.cs @@ -32,7 +32,14 @@ public sealed record PageInfo(string Path, string[]? Anchors, bool Hidden); /// /// Result of getting repository links. /// -public sealed record RepositoryLinksResult(string Repository, OriginInfo Origin, string? UrlPathPrefix, int PageCount, int CrossLinkCount, List Pages); +public sealed record RepositoryLinksResult( + string Repository, + OriginInfo Origin, + string? UrlPathPrefix, + int PageCount, + int CrossLinkCount, + List Pages +); /// /// Information about a cross-link between repositories. @@ -53,4 +60,3 @@ public sealed record BrokenLinkInfo(string FromRepository, string Link, List public sealed record ValidateCrossLinksResult(string Repository, int ValidLinks, int BrokenLinks, List Broken); - diff --git a/src/services/Elastic.Documentation.Assembler/Links/PublishEnvironmentUriResolver.cs b/src/services/Elastic.Documentation.Assembler/Links/PublishEnvironmentUriResolver.cs index ba8fe9ba35..13f39862a5 100644 --- a/src/services/Elastic.Documentation.Assembler/Links/PublishEnvironmentUriResolver.cs +++ b/src/services/Elastic.Documentation.Assembler/Links/PublishEnvironmentUriResolver.cs @@ -67,15 +67,11 @@ public Uri Resolve(Uri crossLinkUri, string path) // If the path starts with the source prefix, get the remainder if (!string.IsNullOrEmpty(sourcePrefix) && path.StartsWith(sourcePrefix, StringComparison.Ordinal)) { - remainingPath = path.Length > sourcePrefix.Length - ? path[sourcePrefix.Length..].TrimStart('/') - : string.Empty; + remainingPath = path.Length > sourcePrefix.Length ? path[sourcePrefix.Length..].TrimStart('/') : string.Empty; } // Build final path: path_prefix + remaining path - var finalPath = string.IsNullOrEmpty(remainingPath) - ? mapping.SourcePathPrefix - : $"{mapping.SourcePathPrefix}/{remainingPath}"; + var finalPath = string.IsNullOrEmpty(remainingPath) ? mapping.SourcePathPrefix : $"{mapping.SourcePathPrefix}/{remainingPath}"; // Apply environment prefix if present if (!string.IsNullOrEmpty(_pathPrefix)) @@ -111,7 +107,9 @@ public Uri Resolve(Uri crossLinkUri, string path) foreach (var mapping in _navigationMappings.Values) { // Build the mapping's source as a string for comparison - var mappingSource = $"{mapping.Source.Scheme}://{mapping.Source.Host}/{mapping.Source.AbsolutePath.TrimStart('/')}".TrimEnd('/'); + var mappingSource = $"{mapping.Source.Scheme}://{mapping.Source.Host}/{mapping.Source.AbsolutePath.TrimStart('/')}".TrimEnd( + '/' + ); // Check if the cross-link starts with this mapping's source if (crossLinkSource.StartsWith(mappingSource, StringComparison.Ordinal)) @@ -149,9 +147,7 @@ public string[] ResolveToSubPaths(Uri crossLinkUri, string path) if (!string.IsNullOrEmpty(sourcePrefix) && path.StartsWith(sourcePrefix, StringComparison.Ordinal)) { - remainingPath = path.Length > sourcePrefix.Length - ? path.Substring(sourcePrefix.Length).TrimStart('/') - : string.Empty; + remainingPath = path.Length > sourcePrefix.Length ? path.Substring(sourcePrefix.Length).TrimStart('/') : string.Empty; } // Build all sub-paths for this URL path diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs b/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs index 31f1eda6a2..1a3ccb489d 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs @@ -53,11 +53,7 @@ IReadOnlySet availableExporters Branch = checkout.Repository.GetBranch(env.ContentSource) }; - var docFs = DocumentationFileSystem.Resolve(path, new DocumentationScopeOptions - { - Output = output, - Git = gitConfiguration, - }); + var docFs = DocumentationFileSystem.Resolve(path, new DocumentationScopeOptions { Output = output, Git = gitConfiguration, }); var buildContext = new BuildContext(context.Collector, docFs, configurationContext) { AvailableExporters = availableExporters, @@ -72,12 +68,10 @@ IReadOnlySet availableExporters Preview = env.GoogleTagManager.Preview, CookiesWin = env.GoogleTagManager.CookiesWin }, - Optimizely = new OptimizelyConfiguration - { - Enabled = env.Optimizely.Enabled, - Id = env.Optimizely.Id - }, - CanonicalBaseUrl = new Uri("https://www.elastic.co"), // Always use the production URL. In case a page is leaked to a search engine, it should point to the production site. + Optimizely = new OptimizelyConfiguration { Enabled = env.Optimizely.Enabled, Id = env.Optimizely.Id }, + CanonicalBaseUrl = + new Uri("https://www.elastic.co"), // Always use the production URL. In case a page is leaked to a search engine, it should point to the production site. + BuildType = BuildType.Assembler }; BuildContext = buildContext; diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs index 495b56ac8a..0a63be5c75 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs @@ -12,7 +12,11 @@ namespace Elastic.Documentation.Assembler.Navigation; #pragma warning disable CS9113 // collector kept for binary-compatibility; no longer used internally -public class GlobalNavigationHtmlWriter(ILoggerFactory logFactory, SiteNavigation globalNavigation, IDiagnosticsCollector collector) : INavigationHtmlWriter +public class GlobalNavigationHtmlWriter( + ILoggerFactory logFactory, + SiteNavigation globalNavigation, + IDiagnosticsCollector collector +) : INavigationHtmlWriter #pragma warning restore CS9113 { private readonly ILogger _logger = logFactory.CreateLogger(); @@ -35,11 +39,14 @@ public async Task RenderNavigation( if (renderRoot is not INodeNavigationItem group) return NavigationRenderResult.Empty; - return await _renderedNavigationCache.GetOrRenderAsync(renderRoot, () => - { - _logger.LogInformation("Rendering navigation for {NavigationTitle} ({Id})", renderRoot.NavigationTitle, renderRoot.Id); - return ((INavigationHtmlWriter)this).Render(CreateNavigationModel(group), ctx); - }); + return await _renderedNavigationCache.GetOrRenderAsync( + renderRoot, + () => + { + _logger.LogInformation("Rendering navigation for {NavigationTitle} ({Id})", renderRoot.NavigationTitle, renderRoot.Id); + return ((INavigationHtmlWriter)this).Render(CreateNavigationModel(group), ctx); + } + ); } private NavigationRenderModel CreateNavigationModel(INodeNavigationItem group) => @@ -50,5 +57,6 @@ private NavigationRenderModel CreateNavigationModel(INodeNavigationItem - { - var source = p.Source.ToString(); - return source.EndsWith(":///", StringComparison.OrdinalIgnoreCase) ? source[..^1] : source; - }) - .OrderByDescending(v => v.Length) + TableOfContentsPrefixes = + [ + .. assembleSources.NavigationTocMappings + .Values + .Select(p => + { + var source = p.Source.ToString(); + return source.EndsWith(":///", StringComparison.OrdinalIgnoreCase) ? source[..^1] : source; + }) + .OrderByDescending(v => v.Length) ]; - PhantomPrefixes = [..navigation.Phantoms - .Select(p => - { - var source = p.Source.ToString(); - return source.EndsWith(":///", StringComparison.OrdinalIgnoreCase) ? source[..^1] : source; - }) - .OrderByDescending(v => v.Length) - .ToArray() + PhantomPrefixes = + [ + .. navigation.Phantoms + .Select(p => + { + var source = p.Source.ToString(); + return source.EndsWith(":///", StringComparison.OrdinalIgnoreCase) ? source[..^1] : source; + }) + .OrderByDescending(v => v.Length) + .ToArray() ]; } @@ -63,7 +67,6 @@ public GlobalNavigationPathProvider(SiteNavigation navigation, AssembleSources a relativePath = Path.GetRelativePath(documentationSet.OutputDirectory.FullName, md.FullName); } - var l = ContentSourceMoniker.CreateString(repositoryName, relativePath).TrimEnd('/'); var lookup = l.AsSpan(); //TODO clean up docs folders in the following repositories @@ -111,7 +114,10 @@ public GlobalNavigationPathProvider(SiteNavigation navigation, AssembleSources a } var fallBack = fs.Path.Join(outputDirectory.FullName, "_failed", repositoryName, relativePath); - _context.Collector.EmitError(_context.ConfigurationFileProvider.NavigationFile, $"No toc for output path: '{lookup}' falling back to: '{fallBack}'"); + _context.Collector.EmitError( + _context.ConfigurationFileProvider.NavigationFile, + $"No toc for output path: '{lookup}' falling back to: '{fallBack}'" + ); return fs.FileInfo.New(fallBack); } diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationService.cs b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationService.cs index 42a9df636e..a9bf485e20 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationService.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationService.cs @@ -43,8 +43,11 @@ public async Task ValidateLocalLinkReference(IDiagnosticsCollector collect var assembleContext = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem); var root = fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); - var repository = GitCheckoutInformationFactory.Create(root, fileSystem, logFactory.CreateLogger(nameof(GitCheckoutInformation))).RepositoryName - ?? throw new Exception("Unable to determine repository name"); + var repository = GitCheckoutInformationFactory.Create( + root, + fileSystem, + logFactory.CreateLogger(nameof(GitCheckoutInformation)) + ).RepositoryName ?? throw new Exception("Unable to determine repository name"); var namespaceChecker = new NavigationPrefixChecker(logFactory, assembleContext); diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/LlmsNavigationEnhancer.cs b/src/services/Elastic.Documentation.Assembler/Navigation/LlmsNavigationEnhancer.cs index da5781603f..6c32c22134 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/LlmsNavigationEnhancer.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/LlmsNavigationEnhancer.cs @@ -53,9 +53,9 @@ public string GenerateNavigationSections(SiteNavigation navigation, Uri canonica return content.ToString(); } - - private static IReadOnlyCollection GetFirstLevelChildren(INodeNavigationItem group) => - group.NavigationItems.Where(i => !i.Hidden).ToArray(); + private static IReadOnlyCollection GetFirstLevelChildren( + INodeNavigationItem group + ) => group.NavigationItems.Where(i => !i.Hidden).ToArray(); /// /// Gets the best title for a navigation item, preferring H1 content over navigation title @@ -63,13 +63,10 @@ private static IReadOnlyCollection GetFirstLevelChildren(INodeN private static string GetBestTitle(INavigationItem navigationItem) => navigationItem switch { // For file navigation items, prefer the H1 title from the Markdown content - ILeafNavigationItem markdownNavigation => - markdownNavigation.Model.Title ?? markdownNavigation.NavigationTitle, - + ILeafNavigationItem markdownNavigation => markdownNavigation.Model.Title ?? markdownNavigation.NavigationTitle, // For documentation groups, try to get the full title of the index INodeNavigationItem markdownNodeNavigation => markdownNodeNavigation.Index.Model.Title ?? markdownNodeNavigation.NavigationTitle, - // For other navigation item types, use the navigation title _ => navigationItem.NavigationTitle }; @@ -78,24 +75,21 @@ private static IReadOnlyCollection GetFirstLevelChildren(INodeN { // Cross-repository links don't have descriptions in frontmatter ILeafNavigationItem => null, - // For file navigation items, extract from frontmatter - ILeafNavigationItem markdownNavigation => - markdownNavigation.Model.YamlFrontMatter?.Description, - + ILeafNavigationItem markdownNavigation => markdownNavigation.Model.YamlFrontMatter?.Description, // For documentation groups, try to get from index file INodeNavigationItem markdownNodeNavigation => markdownNodeNavigation.Index.Model.YamlFrontMatter?.Description, - // we only know about MarkdownFiles for now ILeafNavigationItem => null, INodeNavigationItem => null, - // API-related navigation items (these don't have markdown frontmatter) // Check by namespace to avoid direct assembly references { } item when item.GetType().FullName?.StartsWith("Elastic.ApiExplorer.", StringComparison.Ordinal) == true => null, - // Throw exception for any unhandled navigation item types - _ => throw new InvalidOperationException($"{nameof(LlmsNavigationEnhancer)}.{nameof(GetDescription)}: Unhandled navigation item type: {navigationItem.GetType().FullName}") + _ => + throw new InvalidOperationException( + $"{nameof(LlmsNavigationEnhancer)}.{nameof(GetDescription)}: Unhandled navigation item type: {navigationItem.GetType().FullName}" + ) }; } diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/NavigationPrefixChecker.cs b/src/services/Elastic.Documentation.Assembler/Navigation/NavigationPrefixChecker.cs index 7bbd0f00ba..1d1ea53bfb 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/NavigationPrefixChecker.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/NavigationPrefixChecker.cs @@ -47,9 +47,7 @@ public NavigationPrefixChecker(ILoggerFactory logFactory, AssembleContext contex _phantoms = SiteNavigationFile.GetPhantomPrefixes(siteNavigationFile); - _repositories = context.Configuration.AvailableRepositories.Values - .Select(r => r.Name) - .ToImmutableHashSet(); + _repositories = context.Configuration.AvailableRepositories.Values.Select(r => r.Name).ToImmutableHashSet(); _logger = logFactory.CreateLogger(); _logFactoryFactory = logFactory; @@ -64,7 +62,12 @@ private sealed record SeenPaths public required string Path { get; init; } } - public async Task CheckWithLocalLinksJson(IDiagnosticsCollector collector, string repository, string? localLinksJson, CancellationToken ctx) + public async Task CheckWithLocalLinksJson( + IDiagnosticsCollector collector, + string repository, + string? localLinksJson, + CancellationToken ctx + ) { if (string.IsNullOrEmpty(repository)) throw new ArgumentNullException(nameof(repository)); @@ -78,7 +81,10 @@ public async Task CheckWithLocalLinksJson(IDiagnosticsCollector collector, strin if (!File.Exists(localLinksJson)) { - collector.EmitError(repository, $"Local links file '{localLinksJson}' not found. This usually means the documentation build step failed or was skipped."); + collector.EmitError( + repository, + $"Local links file '{localLinksJson}' not found. This usually means the documentation build step failed or was skipped." + ); return; } @@ -89,7 +95,12 @@ public async Task CheckWithLocalLinksJson(IDiagnosticsCollector collector, strin public async Task CheckAllPublishedLinks(IDiagnosticsCollector collector, Cancel ctx) => await FetchAndValidateCrossLinks(collector, null, null, ctx); - private async Task FetchAndValidateCrossLinks(IDiagnosticsCollector collector, string? updateRepository, RepositoryLinks? updateReference, Cancel ctx) + private async Task FetchAndValidateCrossLinks( + IDiagnosticsCollector collector, + string? updateRepository, + RepositoryLinks? updateReference, + Cancel ctx + ) { var linkIndexProvider = Aws3LinkIndexReader.CreateAnonymous(); var fetcher = new LinksIndexCrossLinkFetcher(_logFactoryFactory, linkIndexProvider); @@ -119,7 +130,10 @@ private async Task FetchAndValidateCrossLinks(IDiagnosticsCollector collector, s var baseOfAPhantom = _phantoms.Any(p => IsPhantomOrDescendant(p, pathUri)); if (baseOfAPhantom) continue; - collector.EmitError(repository, $"'Can not validate '{crossLink}' it's not declared in any link reference nor is it a phantom"); + collector.EmitError( + repository, + $"'Can not validate '{crossLink}' it's not declared in any link reference nor is it a phantom" + ); continue; } foreach (var navigationPath in navigationPaths) @@ -131,20 +145,21 @@ private async Task FetchAndValidateCrossLinks(IDiagnosticsCollector collector, s if (_phantoms.Count > 0 && _phantoms.Contains(new Uri($"{repository}://{navigationPath}"))) continue; - var url = _uriResolver.Resolve(new Uri($"{repository}://{relativeLink}"), PublishEnvironmentUriResolver.MarkdownPathToUrlPath(relativeLink)); - collector.EmitError(repository, - $"'{seen.Repository}' defines: '{seen.Path}' that '{repository}://{relativeLink} resolving to '{url.AbsolutePath}' conflicts with "); + var url = _uriResolver.Resolve( + new Uri($"{repository}://{relativeLink}"), + PublishEnvironmentUriResolver.MarkdownPathToUrlPath(relativeLink) + ); + collector.EmitError( + repository, + $"'{seen.Repository}' defines: '{seen.Path}' that '{repository}://{relativeLink} resolving to '{url.AbsolutePath}' conflicts with " + ); } else { if (_phantoms.Count > 0 && _phantoms.Contains(new Uri($"{repository}://{navigationPath}"))) continue; - dictionary.Add(navigationPath, new SeenPaths - { - Repository = repository, - Path = navigationPath - }); + dictionary.Add(navigationPath, new SeenPaths { Repository = repository, Path = navigationPath }); } } } @@ -152,10 +167,10 @@ private async Task FetchAndValidateCrossLinks(IDiagnosticsCollector collector, s } private static bool IsPhantomOrDescendant(Uri phantom, Uri candidate) => - candidate.Scheme == phantom.Scheme && - candidate.Host == phantom.Host && - (candidate.AbsolutePath == phantom.AbsolutePath || - candidate.AbsolutePath.StartsWith(phantom.AbsolutePath.TrimEnd('/') + '/', StringComparison.Ordinal)); + candidate.Scheme == phantom.Scheme + && candidate.Host == phantom.Host + && (candidate.AbsolutePath == phantom.AbsolutePath || + candidate.AbsolutePath.StartsWith(phantom.AbsolutePath.TrimEnd('/') + '/', StringComparison.Ordinal)); private async Task ReadLocalLinksJsonAsync(string localLinksJson, Cancel ctx) { diff --git a/src/services/Elastic.Documentation.Assembler/Sourcing/GitFacade.cs b/src/services/Elastic.Documentation.Assembler/Sourcing/GitFacade.cs index 1a7244a3cc..63b751e5aa 100644 --- a/src/services/Elastic.Documentation.Assembler/Sourcing/GitFacade.cs +++ b/src/services/Elastic.Documentation.Assembler/Sourcing/GitFacade.cs @@ -24,16 +24,25 @@ public interface IGitRepository // This git repository implementation is optimized for pull and fetching single commits. // It uses `git pull --depth 1` and `git fetch --depth 1` to minimize the amount of data transferred. -public class SingleCommitOptimizedGitRepository(ILoggerFactory logFactory, IDiagnosticsCollector collector, IDirectoryInfo workingDirectory) - : ExternalCommandExecutor(collector, workingDirectory, Environment.GetEnvironmentVariable("CI") is null or "" ? null : TimeSpan.FromMinutes(10)) - , IGitRepository +public class SingleCommitOptimizedGitRepository( + ILoggerFactory logFactory, + IDiagnosticsCollector collector, + IDirectoryInfo workingDirectory +) : ExternalCommandExecutor( + collector, + workingDirectory, + Environment.GetEnvironmentVariable("CI") is null or "" ? null : TimeSpan.FromMinutes(10) +), IGitRepository { private static readonly Dictionary EnvironmentVars = new() { // Disable git editor prompts: // There are cases where `git pull` would prompt for an editor to write a commit message. // This env variable prevents that. - { "GIT_EDITOR", "true" } + { + "GIT_EDITOR", + "true" + } }; // Only the network bound commands retry. CloneRef wraps this with up to 3 wipe-and-reclone passes, @@ -47,9 +56,37 @@ public class SingleCommitOptimizedGitRepository(ILoggerFactory logFactory, IDiag public void Init() => ExecIn(EnvironmentVars, "git", "init"); public bool IsInitialized() => Directory.Exists(Path.Join(WorkingDirectory.FullName, ".git")); - public void Pull(string branch) => _ = ExecInWithRetry(EnvironmentVars, NetworkRetry, "git", "pull", "--depth", "1", "--allow-unrelated-histories", "--no-ff", "origin", branch); - public void Fetch(string reference) => _ = ExecInWithRetry(EnvironmentVars, NetworkRetry, "git", "fetch", "--no-tags", "--prune", "--no-recurse-submodules", "--depth", "1", "origin", reference); - public void EnableSparseCheckout(string[] folders) => ExecIn(EnvironmentVars, "git", ["sparse-checkout", "set", "--no-cone", .. folders]); + public void Pull(string branch) => + _ = + ExecInWithRetry( + EnvironmentVars, + NetworkRetry, + "git", + "pull", + "--depth", + "1", + "--allow-unrelated-histories", + "--no-ff", + "origin", + branch + ); + public void Fetch(string reference) => + _ = + ExecInWithRetry( + EnvironmentVars, + NetworkRetry, + "git", + "fetch", + "--no-tags", + "--prune", + "--no-recurse-submodules", + "--depth", + "1", + "origin", + reference + ); + public void EnableSparseCheckout(string[] folders) => + ExecIn(EnvironmentVars, "git", ["sparse-checkout", "set", "--no-cone", .. folders]); public void DisableSparseCheckout() => ExecIn(EnvironmentVars, "git", "sparse-checkout", "disable"); public void Checkout(string reference) => ExecIn(EnvironmentVars, "git", "checkout", "--force", reference); diff --git a/src/services/Elastic.Documentation.Assembler/Sourcing/RepositorySourcesFetcher.cs b/src/services/Elastic.Documentation.Assembler/Sourcing/RepositorySourcesFetcher.cs index f53f37c40d..6c73aa7936 100644 --- a/src/services/Elastic.Documentation.Assembler/Sourcing/RepositorySourcesFetcher.cs +++ b/src/services/Elastic.Documentation.Assembler/Sourcing/RepositorySourcesFetcher.cs @@ -39,9 +39,17 @@ public CheckoutResult GetAll() Paths.ValidateSinglePathSegment(repo.Name, nameof(repo.Name)); var checkoutFolder = fs.DirectoryInfo.New(Path.Join(context.CheckoutDirectory.FullName, repo.Name)); // if we are running locally, always allow repository path overrides. Otherwise, only for docs-builder. - if (!string.IsNullOrWhiteSpace(repo.Path) && (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("CI")) || repo.Name == "docs-builder")) + if ( + !string.IsNullOrWhiteSpace(repo.Path) && + (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("CI")) || repo.Name == "docs-builder") + ) { - _logger.LogInformation("{RepositoryName}: Using local override path for {RepositoryName} at {Path}", repo.Name, repo.Name, repo.Path); + _logger.LogInformation( + "{RepositoryName}: Using local override path for {RepositoryName} at {Path}", + repo.Name, + repo.Name, + repo.Path + ); checkoutFolder = fs.DirectoryInfo.New(repo.Path); } IGitRepository gitFacade = new SingleCommitOptimizedGitRepository(logFactory, context.Collector, checkoutFolder); @@ -51,24 +59,16 @@ public CheckoutResult GetAll() continue; } var head = gitFacade.GetCurrentCommit(); - var checkout = new Checkout - { - Repository = repo, - Directory = checkoutFolder, - HeadReference = head - }; + var checkout = new Checkout { Repository = repo, Directory = checkoutFolder, HeadReference = head }; checkouts.Add(checkout); } - return new CheckoutResult - { - Checkouts = checkouts, - LinkRegistrySnapshot = linkRegistry - }; + return new CheckoutResult { Checkouts = checkouts, LinkRegistrySnapshot = linkRegistry }; } public async Task CloneAll(bool fetchLatest, bool assumeCloned, Cancel ctx = default) { - _logger.LogInformation("Cloning all repositories for environment {EnvironmentName} using '{ContentSourceStrategy}' content sourcing strategy", + _logger.LogInformation( + "Cloning all repositories for environment {EnvironmentName} using '{ContentSourceStrategy}' content sourcing strategy", PublishEnvironment.Name, PublishEnvironment.ContentSource.ToStringFast(true) ); @@ -77,57 +77,66 @@ public async Task CloneAll(bool fetchLatest, bool assumeCloned, ILinkIndexReader linkIndexReader = Aws3LinkIndexReader.CreateAnonymous(); var linkRegistry = await linkIndexReader.GetRegistry(ctx); - await Parallel.ForEachAsync(Configuration.AvailableRepositories, - new ParallelOptions + await Parallel.ForEachAsync( + Configuration.AvailableRepositories, + new ParallelOptions { CancellationToken = ctx, MaxDegreeOfParallelism = Environment.ProcessorCount }, + async (repo, c) => { - CancellationToken = ctx, - MaxDegreeOfParallelism = Environment.ProcessorCount - }, async (repo, c) => - { - await Task.Run(() => - { - if (!linkRegistry.Repositories.TryGetValue(repo.Key, out var entry)) - { - context.Collector.EmitError("", $"'{repo.Key}' does not exist in link index"); - return; - } - var branch = repo.Value.GetBranch(PublishEnvironment.ContentSource); - var gitRef = branch; - if (!fetchLatest) + await Task.Run( + () => { - if (!entry.TryGetValue(branch, out var entryInfo)) + if (!linkRegistry.Repositories.TryGetValue(repo.Key, out var entry)) { - context.Collector.EmitError("", $"'{repo.Key}' does not have a '{branch}' entry in link index"); + context.Collector.EmitError("", $"'{repo.Key}' does not exist in link index"); return; } - gitRef = entryInfo.GitReference; - } + var branch = repo.Value.GetBranch(PublishEnvironment.ContentSource); + var gitRef = branch; + if (!fetchLatest) + { + if (!entry.TryGetValue(branch, out var entryInfo)) + { + context.Collector.EmitError("", $"'{repo.Key}' does not have a '{branch}' entry in link index"); + return; + } + gitRef = entryInfo.GitReference; + } - var cloneInformation = RepositorySourcer.CloneRef(repo.Value, gitRef, fetchLatest, assumeCloned: assumeCloned); - checkouts.Add(cloneInformation); - }, c); - }).ConfigureAwait(false); - await context.WriteFileSystem.File.WriteAllTextAsync( - Path.Join(context.CheckoutDirectory.FullName, CheckoutResult.LinkRegistrySnapshotFileName), - LinkRegistry.Serialize(linkRegistry), - ctx - ); - return new CheckoutResult - { - Checkouts = checkouts, - LinkRegistrySnapshot = linkRegistry - }; + var cloneInformation = RepositorySourcer.CloneRef(repo.Value, gitRef, fetchLatest, assumeCloned: assumeCloned); + checkouts.Add(cloneInformation); + }, + c + ); + } + ).ConfigureAwait(false); + await context.WriteFileSystem + .File + .WriteAllTextAsync( + Path.Join(context.CheckoutDirectory.FullName, CheckoutResult.LinkRegistrySnapshotFileName), + LinkRegistry.Serialize(linkRegistry), + ctx + ); + return new CheckoutResult { Checkouts = checkouts, LinkRegistrySnapshot = linkRegistry }; } - public async Task WriteLinkRegistrySnapshot(LinkRegistry linkRegistrySnapshot, Cancel ctx = default) => await context.WriteFileSystem.File.WriteAllTextAsync( - context.WriteFileSystem.Path.Join(context.OutputWithPathPrefixDirectory.FullName, CheckoutResult.LinkRegistrySnapshotFileName), - LinkRegistry.Serialize(linkRegistrySnapshot), - ctx - ); + public async Task WriteLinkRegistrySnapshot(LinkRegistry linkRegistrySnapshot, Cancel ctx = default) => + await context.WriteFileSystem + .File + .WriteAllTextAsync( + context.WriteFileSystem + .Path + .Join(context.OutputWithPathPrefixDirectory.FullName, CheckoutResult.LinkRegistrySnapshotFileName), + LinkRegistry.Serialize(linkRegistrySnapshot), + ctx + ); } - -public class RepositorySourcer(ILoggerFactory logFactory, IDirectoryInfo checkoutDirectory, IFileSystem readFileSystem, IDiagnosticsCollector collector) +public class RepositorySourcer( + ILoggerFactory logFactory, + IDirectoryInfo checkoutDirectory, + IFileSystem readFileSystem, + IDiagnosticsCollector collector +) { private readonly ILogger _logger = logFactory.CreateLogger(); @@ -136,14 +145,20 @@ public class RepositorySourcer(ILoggerFactory logFactory, IDirectoryInfo checkou // // The repository to clone. // The git reference to check out. Branch, commit or tag - public Checkout CloneRef(Repository repository, string gitRef, bool pull = false, int attempt = 1, bool appendRepositoryName = true, bool assumeCloned = false) + public Checkout CloneRef( + Repository repository, + string gitRef, + bool pull = false, + int attempt = 1, + bool appendRepositoryName = true, + bool assumeCloned = false + ) { if (appendRepositoryName) Paths.ValidateSinglePathSegment(repository.Name, nameof(repository.Name)); - var checkoutFolder = - appendRepositoryName - ? readFileSystem.DirectoryInfo.New(Path.Join(checkoutDirectory.FullName, repository.Name)) - : checkoutDirectory; + var checkoutFolder = appendRepositoryName + ? readFileSystem.DirectoryInfo.New(Path.Join(checkoutDirectory.FullName, repository.Name)) + : checkoutDirectory; // if we are running locally, allow for repository path override if (!string.IsNullOrWhiteSpace(repository.Path)) @@ -151,20 +166,38 @@ public Checkout CloneRef(Repository repository, string gitRef, bool pull = false var di = readFileSystem.DirectoryInfo.New(repository.Path); if (!di.Exists) { - _logger.LogInformation("{RepositoryName}: Can not find {RepositoryName}@{Commit} at local override path {CheckoutFolder}", repository.Name, repository.Name, gitRef, di.FullName); + _logger.LogInformation( + "{RepositoryName}: Can not find {RepositoryName}@{Commit} at local override path {CheckoutFolder}", + repository.Name, + repository.Name, + gitRef, + di.FullName + ); collector.EmitError("", $"Can not find {repository.Name}@{gitRef} at local override path {di.FullName}"); return new Checkout { Directory = di, HeadReference = "", Repository = repository }; } checkoutFolder = di; assumeCloned = true; - _logger.LogInformation("{RepositoryName}: Using override path for {RepositoryName}@{Commit} at {CheckoutFolder}", repository.Name, repository.Name, gitRef, checkoutFolder.FullName); + _logger.LogInformation( + "{RepositoryName}: Using override path for {RepositoryName}@{Commit} at {CheckoutFolder}", + repository.Name, + repository.Name, + gitRef, + checkoutFolder.FullName + ); } IGitRepository git = new SingleCommitOptimizedGitRepository(logFactory, collector, checkoutFolder); if (assumeCloned && checkoutFolder.Exists) { - _logger.LogInformation("{RepositoryName}: Assuming {RepositoryName}@{Commit} is already checked out to {CheckoutFolder}", repository.Name, repository.Name, gitRef, checkoutFolder.FullName); + _logger.LogInformation( + "{RepositoryName}: Assuming {RepositoryName}@{Commit} is already checked out to {CheckoutFolder}", + repository.Name, + repository.Name, + gitRef, + checkoutFolder.FullName + ); return new Checkout { Directory = checkoutFolder, HeadReference = git.GetCurrentCommit(), Repository = repository }; } @@ -173,8 +206,13 @@ public Checkout CloneRef(Repository repository, string gitRef, bool pull = false collector.EmitError("", $"Failed to clone repository {repository.Name}@{gitRef} after 3 attempts"); return new Checkout { Directory = checkoutFolder, HeadReference = "", Repository = repository }; } - _logger.LogInformation("{RepositoryName}: Cloning repository {RepositoryName}@{Commit} to {CheckoutFolder}", repository.Name, repository.Name, gitRef, - checkoutFolder.FullName); + _logger.LogInformation( + "{RepositoryName}: Cloning repository {RepositoryName}@{Commit} to {CheckoutFolder}", + repository.Name, + repository.Name, + gitRef, + checkoutFolder.FullName + ); if (!checkoutFolder.Exists) { checkoutFolder.Create(); @@ -190,7 +228,11 @@ public Checkout CloneRef(Repository repository, string gitRef, bool pull = false } catch (Exception e) { - _logger.LogError(e, "{RepositoryName}: Failed to acquire current commit, falling back to recreating from scratch", repository.Name); + _logger.LogError( + e, + "{RepositoryName}: Failed to acquire current commit, falling back to recreating from scratch", + repository.Name + ); checkoutFolder.Delete(true); checkoutFolder.Refresh(); return CloneRef(repository, gitRef, pull, attempt + 1, appendRepositoryName, assumeCloned); @@ -205,12 +247,7 @@ public Checkout CloneRef(Repository repository, string gitRef, bool pull = false FetchAndCheckout(git, repository, gitRef); if (!pull) { - return new Checkout - { - Directory = checkoutFolder, - HeadReference = git.GetCurrentCommit(), - Repository = repository, - }; + return new Checkout { Directory = checkoutFolder, HeadReference = git.GetCurrentCommit(), Repository = repository, }; } try { @@ -218,20 +255,20 @@ public Checkout CloneRef(Repository repository, string gitRef, bool pull = false } catch (Exception e) { - _logger.LogError(e, "{RepositoryName}: Failed to update {GitRef} from {Path}, falling back to recreating from scratch", - repository.Name, gitRef, checkoutFolder.FullName); + _logger.LogError( + e, + "{RepositoryName}: Failed to update {GitRef} from {Path}, falling back to recreating from scratch", + repository.Name, + gitRef, + checkoutFolder.FullName + ); checkoutFolder.Delete(true); checkoutFolder.Refresh(); return CloneRef(repository, gitRef, pull, attempt + 1, appendRepositoryName, assumeCloned); } } - return new Checkout - { - Directory = checkoutFolder, - HeadReference = git.GetCurrentCommit(), - Repository = repository, - }; + return new Checkout { Directory = checkoutFolder, HeadReference = git.GetCurrentCommit(), Repository = repository, }; } /// diff --git a/src/services/Elastic.Documentation.Deploying/IncrementalDeployService.cs b/src/services/Elastic.Documentation.Deploying/IncrementalDeployService.cs index 09493d643b..112ad1e0cb 100644 --- a/src/services/Elastic.Documentation.Deploying/IncrementalDeployService.cs +++ b/src/services/Elastic.Documentation.Deploying/IncrementalDeployService.cs @@ -24,7 +24,15 @@ public class IncrementalDeployService( private readonly ILogger _logger = logFactory.CreateLogger(); private readonly IAmazonS3 _s3 = s3Client ?? new AmazonS3Client(); - public async Task Plan(IDiagnosticsCollector collector, IDocsSyncContext context, string s3BucketName, string @out, float? deleteThreshold, string[] excludePatterns, Cancel ctx) + public async Task Plan( + IDiagnosticsCollector collector, + IDocsSyncContext context, + string s3BucketName, + string @out, + float? deleteThreshold, + string[] excludePatterns, + Cancel ctx + ) { if (excludePatterns.Length > 0) _logger.LogInformation("Excluding patterns from sync: {ExcludePatterns}", string.Join(", ", excludePatterns)); @@ -36,7 +44,10 @@ public async Task Plan(IDiagnosticsCollector collector, IDocsSyncContext c if (!validationResult.Valid) { await githubActionsService.SetOutputAsync("plan-valid", "false"); - collector.EmitError(@out, $"Plan is invalid, {validationResult}, delete ratio: {validationResult.DeleteRatio}, remote listing completed: {plan.RemoteListingCompleted}"); + collector.EmitError( + @out, + $"Plan is invalid, {validationResult}, delete ratio: {validationResult.DeleteRatio}, remote listing completed: {plan.RemoteListingCompleted}" + ); return false; } @@ -52,13 +63,23 @@ public async Task Plan(IDiagnosticsCollector collector, IDocsSyncContext c return collector.Errors == 0; } - public async Task Apply(IDiagnosticsCollector collector, IDocsSyncContext context, string s3BucketName, string planFile, Cancel ctx) + public async Task Apply( + IDiagnosticsCollector collector, + IDocsSyncContext context, + string s3BucketName, + string planFile, + Cancel ctx + ) { - var xfer = transferUtility ?? new TransferUtility(_s3, new TransferUtilityConfig - { - ConcurrentServiceRequests = Environment.ProcessorCount * 2, - MinSizeBeforePartUpload = S3EtagCalculator.PartSize - }); + var xfer = transferUtility ?? + new TransferUtility( + _s3, + new TransferUtilityConfig + { + ConcurrentServiceRequests = Environment.ProcessorCount * 2, + MinSizeBeforePartUpload = S3EtagCalculator.PartSize + } + ); if (!context.ReadFileSystem.File.Exists(planFile)) { collector.EmitError(planFile, "Plan file does not exist."); @@ -77,7 +98,10 @@ public async Task Apply(IDiagnosticsCollector collector, IDocsSyncContext var validationResult = validator.Validate(plan); if (!validationResult.Valid) { - collector.EmitError(planFile, $"Plan is invalid, {validationResult}, delete ratio: {validationResult.DeleteRatio}, remote listing completed: {plan.RemoteListingCompleted}"); + collector.EmitError( + planFile, + $"Plan is invalid, {validationResult}, delete ratio: {validationResult.DeleteRatio}, remote listing completed: {plan.RemoteListingCompleted}" + ); return false; } var applier = new AwsS3SyncApplyStrategy(logFactory, _s3, xfer, s3BucketName, context, collector); diff --git a/src/services/Elastic.Documentation.Deploying/Synchronization/AwsS3SyncApplyStrategy.cs b/src/services/Elastic.Documentation.Deploying/Synchronization/AwsS3SyncApplyStrategy.cs index 54e083d452..873e8c094d 100644 --- a/src/services/Elastic.Documentation.Deploying/Synchronization/AwsS3SyncApplyStrategy.cs +++ b/src/services/Elastic.Documentation.Deploying/Synchronization/AwsS3SyncApplyStrategy.cs @@ -33,62 +33,65 @@ IDiagnosticsCollector collector private static readonly Histogram FilesPerDeploymentHistogram = SyncMeter.CreateHistogram( "docs.deployment.files.count", "files", - "Number of files per deployment operation (added + updated + deleted + skipped)"); + "Number of files per deployment operation (added + updated + deleted + skipped)" + ); private static readonly Counter FilesTotalCounter = SyncMeter.CreateCounter( "docs.deployment.files.total", "files", - "Total number of files in deployment (added + updated + deleted + skipped)"); + "Total number of files in deployment (added + updated + deleted + skipped)" + ); private static readonly Counter FilesAddedCounter = SyncMeter.CreateCounter( "docs.sync.files.added.total", "files", - "Total number of files added to S3"); + "Total number of files added to S3" + ); private static readonly Counter FilesUpdatedCounter = SyncMeter.CreateCounter( "docs.sync.files.updated.total", "files", - "Total number of files updated in S3"); + "Total number of files updated in S3" + ); private static readonly Counter FilesDeletedCounter = SyncMeter.CreateCounter( "docs.sync.files.deleted.total", "files", - "Total number of files deleted from S3"); + "Total number of files deleted from S3" + ); private static readonly Counter FilesSkippedCounter = SyncMeter.CreateCounter( "docs.sync.files.skipped.total", "files", - "Total number of files skipped (unchanged)"); + "Total number of files skipped (unchanged)" + ); private static readonly Histogram FileSizeHistogram = SyncMeter.CreateHistogram( "docs.sync.file.size", "By", - "Distribution of file sizes synced to S3"); + "Distribution of file sizes synced to S3" + ); private static readonly Counter FilesByExtensionCounter = SyncMeter.CreateCounter( "docs.sync.files.by_extension", "files", - "File operations grouped by extension"); + "File operations grouped by extension" + ); private static readonly Histogram SyncDurationHistogram = SyncMeter.CreateHistogram( "docs.sync.duration", "s", - "Duration of sync operations"); + "Duration of sync operations" + ); private readonly ILogger _logger = logFactory.CreateLogger(); private void DisplayProgress(object? sender, UploadDirectoryProgressArgs args) => LogProgress(_logger, args); - [LoggerMessage( - EventId = 2, - Level = LogLevel.Debug, - Message = "{Args}")] + [LoggerMessage(EventId = 2, Level = LogLevel.Debug, Message = "{Args}")] private static partial void LogProgress(ILogger logger, UploadDirectoryProgressArgs args); - [LoggerMessage( - EventId = 3, - Level = LogLevel.Information, - Message = "File operation: {Operation} | Path: {FilePath} | Size: {FileSize} bytes")] + [LoggerMessage(EventId = 3, Level = LogLevel.Information, Message = "File operation: {Operation} | Path: {FilePath} | Size: {FileSize} bytes")] private static partial void LogFileOperation(ILogger logger, string operation, string filePath, long fileSize); public async Task Apply(SyncPlan plan, Cancel ctx = default) @@ -137,7 +140,13 @@ public async Task Apply(SyncPlan plan, Cancel ctx = default) _logger.LogInformation( "Deployment sync: {TotalFiles} files ({AddCount} added, {UpdateCount} updated, {DeleteCount} deleted, {SkipCount} skipped) in {Environment}", - totalFiles, addCount, updateCount, deleteCount, skipCount, context.EnvironmentName); + totalFiles, + addCount, + updateCount, + deleteCount, + skipCount, + context.EnvironmentName + ); await Upload(plan, ctx); await Delete(plan, ctx); @@ -195,8 +204,12 @@ private async Task Upload(SyncPlan plan, Cancel ctx) _logger.LogInformation("Uploading {Count} files to S3 bucket {BucketName}", uploadRequests.Count, bucketName); _logger.LogDebug("Starting directory upload from {TempDir}", tempDir); await transferUtility.UploadDirectoryAsync(directoryRequest, ctx); - _logger.LogInformation("Successfully uploaded {Count} files ({AddCount} added, {UpdateCount} updated)", - uploadRequests.Count, addCount, updateCount); + _logger.LogInformation( + "Successfully uploaded {Count} files ({AddCount} added, {UpdateCount} updated)", + uploadRequests.Count, + addCount, + updateCount + ); } finally { @@ -228,9 +241,7 @@ private async Task Delete(SyncPlan plan, Cancel ctx) // Record by extension (low cardinality) if (!string.IsNullOrEmpty(extension)) { - FilesByExtensionCounter.Add(1, - new("operation", "delete"), - new("extension", extension)); + FilesByExtensionCounter.Add(1, new("operation", "delete"), new("extension", extension)); } // Log individual file operations for detailed analysis @@ -243,10 +254,7 @@ private async Task Delete(SyncPlan plan, Cancel ctx) var deleteObjectsRequest = new DeleteObjectsRequest { BucketName = bucketName, - Objects = batch.Select(d => new KeyVersion - { - Key = d.DestinationPath - }).ToList() + Objects = batch.Select(d => new KeyVersion { Key = d.DestinationPath }).ToList() }; var response = await s3Client.DeleteObjectsAsync(deleteObjectsRequest, ctx); if (response.HttpStatusCode != System.Net.HttpStatusCode.OK) @@ -261,8 +269,12 @@ private async Task Delete(SyncPlan plan, Cancel ctx) else { deleteCount += batch.Length; - _logger.LogInformation("Deleted {BatchCount} files ({CurrentCount}/{TotalCount})", - batch.Length, deleteCount, deleteRequests.Count); + _logger.LogInformation( + "Deleted {BatchCount} files ({CurrentCount}/{TotalCount})", + batch.Length, + deleteCount, + deleteRequests.Count + ); } } diff --git a/src/services/Elastic.Documentation.Deploying/Synchronization/AwsS3SyncPlanStrategy.cs b/src/services/Elastic.Documentation.Deploying/Synchronization/AwsS3SyncPlanStrategy.cs index 496700c845..83759fcb98 100644 --- a/src/services/Elastic.Documentation.Deploying/Synchronization/AwsS3SyncPlanStrategy.cs +++ b/src/services/Elastic.Documentation.Deploying/Synchronization/AwsS3SyncPlanStrategy.cs @@ -17,8 +17,7 @@ public class AwsS3SyncPlanStrategy( string bucketName, IDocsSyncContext context, IS3EtagCalculator? calculator = null -) - : IDocsSyncPlanStrategy +) : IDocsSyncPlanStrategy { private readonly IS3EtagCalculator _s3EtagCalculator = calculator ?? new S3EtagCalculator(logFactory, context.ReadFileSystem); @@ -28,8 +27,7 @@ private bool IsSymlink(string path) return fileInfo.LinkTarget != null; } - private static bool IsExcluded(string destinationPath, Glob[] globs) => - globs.Length > 0 && globs.Any(g => g.IsMatch(destinationPath)); + private static bool IsExcluded(string destinationPath, Glob[] globs) => globs.Length > 0 && globs.Any(g => g.IsMatch(destinationPath)); /// /// Normalize an exclude pattern so it behaves like aws s3 sync --exclude, where @@ -46,57 +44,47 @@ public async Task Plan(float? deleteThreshold, string[] excludePattern // Start S3 listing in background while scanning local files concurrently var listTask = ListObjects(ctx); - var localObjects = context.OutputDirectory.GetFiles("*", SearchOption.AllDirectories) - .Where(f => !IsSymlink(f.FullName)) - .ToArray(); + var localObjects = context.OutputDirectory.GetFiles("*", SearchOption.AllDirectories).Where(f => !IsSymlink(f.FullName)).ToArray(); var (readToCompletion, remoteObjects) = await listTask; var deleteRequests = new ConcurrentBag(); var addRequests = new ConcurrentBag(); var updateRequests = new ConcurrentBag(); var skipRequests = new ConcurrentBag(); - await Parallel.ForEachAsync(localObjects, ctx, async (localFile, token) => - { - var relativePath = Path.GetRelativePath(context.OutputDirectory.FullName, localFile.FullName); - var destinationPath = relativePath.Replace('\\', '/'); + await Parallel.ForEachAsync( + localObjects, + ctx, + async (localFile, token) => + { + var relativePath = Path.GetRelativePath(context.OutputDirectory.FullName, localFile.FullName); + var destinationPath = relativePath.Replace('\\', '/'); - if (IsExcluded(destinationPath, excludeGlobs)) - return; + if (IsExcluded(destinationPath, excludeGlobs)) + return; - if (remoteObjects.TryGetValue(destinationPath, out var remoteObject)) - { - // Check if the ETag differs for updates - var localETag = await _s3EtagCalculator.CalculateS3ETag(localFile.FullName, token); - var remoteETag = remoteObject.ETag.Trim('"'); // Remove quotes from remote ETag - if (localETag == remoteETag) + if (remoteObjects.TryGetValue(destinationPath, out var remoteObject)) { - var skipRequest = new SkipRequest + // Check if the ETag differs for updates + var localETag = await _s3EtagCalculator.CalculateS3ETag(localFile.FullName, token); + var remoteETag = remoteObject.ETag.Trim('"'); // Remove quotes from remote ETag + if (localETag == remoteETag) + { + var skipRequest = new SkipRequest { LocalPath = localFile.FullName, DestinationPath = remoteObject.Key }; + skipRequests.Add(skipRequest); + } + else { - LocalPath = localFile.FullName, - DestinationPath = remoteObject.Key - }; - skipRequests.Add(skipRequest); + var updateRequest = new UpdateRequest() { LocalPath = localFile.FullName, DestinationPath = remoteObject.Key }; + updateRequests.Add(updateRequest); + } } else { - var updateRequest = new UpdateRequest() - { - LocalPath = localFile.FullName, - DestinationPath = remoteObject.Key - }; - updateRequests.Add(updateRequest); + var addRequest = new AddRequest { LocalPath = localFile.FullName, DestinationPath = destinationPath }; + addRequests.Add(addRequest); } } - else - { - var addRequest = new AddRequest - { - LocalPath = localFile.FullName, - DestinationPath = destinationPath - }; - addRequests.Add(addRequest); - } - }); + ); // Find deletions (files in S3 but not locally), honouring excludes foreach (var remoteObject in remoteObjects) @@ -106,10 +94,7 @@ await Parallel.ForEachAsync(localObjects, ctx, async (localFile, token) => var localPath = Path.Join(context.OutputDirectory.FullName, remoteObject.Key.Replace('/', Path.DirectorySeparatorChar)); if (context.ReadFileSystem.File.Exists(localPath)) continue; - var deleteRequest = new DeleteRequest - { - DestinationPath = remoteObject.Key - }; + var deleteRequest = new DeleteRequest { DestinationPath = remoteObject.Key }; deleteRequests.Add(deleteRequest); } @@ -130,11 +115,7 @@ await Parallel.ForEachAsync(localObjects, ctx, async (localFile, token) => private async Task<(bool readToCompletion, Dictionary objects)> ListObjects(Cancel ctx = default) { - var listBucketRequest = new ListObjectsV2Request - { - BucketName = bucketName, - MaxKeys = 1000 - }; + var listBucketRequest = new ListObjectsV2Request { BucketName = bucketName, MaxKeys = 1000 }; var objects = new List(); var bucketExists = await S3BucketExists(ctx); if (!bucketExists) @@ -159,7 +140,8 @@ await Parallel.ForEachAsync(localObjects, ctx, async (localFile, token) => } objects.AddRange(response.S3Objects); listBucketRequest.ContinuationToken = response.NextContinuationToken; - } while (response.IsTruncated == true); + } + while (response.IsTruncated == true); return (readToCompletion, objects.ToDictionary(o => o.Key)); } @@ -169,10 +151,7 @@ private async Task S3BucketExists(Cancel ctx) //https://docs.aws.amazon.com/code-library/latest/ug/s3_example_s3_Scenario_DoesBucketExist_section.html try { - _ = await s3Client.GetBucketAclAsync(new GetBucketAclRequest - { - BucketName = bucketName - }, ctx); + _ = await s3Client.GetBucketAclAsync(new GetBucketAclRequest { BucketName = bucketName }, ctx); return true; } catch diff --git a/src/services/Elastic.Documentation.Deploying/Synchronization/DocsSync.cs b/src/services/Elastic.Documentation.Deploying/Synchronization/DocsSync.cs index 104f65fd6c..7fe873f9f3 100644 --- a/src/services/Elastic.Documentation.Deploying/Synchronization/DocsSync.cs +++ b/src/services/Elastic.Documentation.Deploying/Synchronization/DocsSync.cs @@ -91,7 +91,7 @@ public record SyncPlan public static SyncPlan Deserialize(string json) => JsonSerializer.Deserialize(json, SyncSerializerContext.Default.SyncPlan) ?? - throw new JsonException("Failed to deserialize SyncPlan from JSON"); + throw new JsonException("Failed to deserialize SyncPlan from JSON"); } [JsonSourceGenerationOptions(WriteIndented = true, UseStringEnumConverter = true)] diff --git a/src/services/Elastic.Documentation.Deploying/Synchronization/DocsSyncPlanValidator.cs b/src/services/Elastic.Documentation.Deploying/Synchronization/DocsSyncPlanValidator.cs index bc030a58e8..d49d7229cf 100644 --- a/src/services/Elastic.Documentation.Deploying/Synchronization/DocsSyncPlanValidator.cs +++ b/src/services/Elastic.Documentation.Deploying/Synchronization/DocsSyncPlanValidator.cs @@ -31,7 +31,9 @@ public PlanValidationResult Validate(SyncPlan plan) var deleteRatio = (float)plan.DeleteRequests.Count / plan.TotalRemoteFiles; if (plan.TotalRemoteFiles == 0) { - _logger.LogInformation("No files discovered in S3, assuming a clean bucket resetting delete threshold to `0.0' as our plan should not have ANY deletions"); + _logger.LogInformation( + "No files discovered in S3, assuming a clean bucket resetting delete threshold to `0.0' as our plan should not have ANY deletions" + ); deleteThreshold = 0.0f; } // if the total remote files are less than or equal to 100, we enforce a higher ratio of 0.8 @@ -41,12 +43,13 @@ public PlanValidationResult Validate(SyncPlan plan) _logger.LogInformation("Plan has less than 100 total remote files ensuring delete threshold is at minimum 0.8"); deleteThreshold = Math.Max(deleteThreshold, 0.8f); } - // if the total remote files are less than or equal to 1000, we enforce a higher ratio of 0.5 // this allows newer assembled documentation to be in a higher state of flux else if (plan.TotalRemoteFiles <= 1000) { - _logger.LogInformation("Plan has less than 1000 but more than a 100 total remote files ensuring delete threshold is at minimum 0.5"); + _logger.LogInformation( + "Plan has less than 1000 but more than a 100 total remote files ensuring delete threshold is at minimum 0.5" + ); deleteThreshold = Math.Max(deleteThreshold, 0.5f); } diff --git a/src/services/Elastic.Documentation.Integrations/S3/S3IncrementalUploader.cs b/src/services/Elastic.Documentation.Integrations/S3/S3IncrementalUploader.cs index f774d71e7a..5190d5b6d0 100644 --- a/src/services/Elastic.Documentation.Integrations/S3/S3IncrementalUploader.cs +++ b/src/services/Elastic.Documentation.Integrations/S3/S3IncrementalUploader.cs @@ -76,11 +76,7 @@ public async Task Upload(IReadOnlyList targets, bool { try { - var response = await s3Client.GetObjectMetadataAsync(new GetObjectMetadataRequest - { - BucketName = bucketName, - Key = key - }, ctx); + var response = await s3Client.GetObjectMetadataAsync(new GetObjectMetadataRequest { BucketName = bucketName, Key = key }, ctx); return response.ETag.Trim('"'); } catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) diff --git a/src/services/Elastic.Documentation.Isolated/ExporterParser.cs b/src/services/Elastic.Documentation.Isolated/ExporterParser.cs index 72c26b6710..519bf5ad8a 100644 --- a/src/services/Elastic.Documentation.Isolated/ExporterParser.cs +++ b/src/services/Elastic.Documentation.Isolated/ExporterParser.cs @@ -71,7 +71,8 @@ public bool TryParse(string raw, out IReadOnlySet result) break; default: throw new ArgumentException( - $"Unknown exporter '{token}'. Valid values: html, llm, es, config, links, state, redirects, okf, pagefind, default, metadata, none."); + $"Unknown exporter '{token}'. Valid values: html, llm, es, config, links, state, redirects, okf, pagefind, default, metadata, none." + ); } } result = set; diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs index b492828af7..74fc77658b 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs @@ -81,11 +81,10 @@ public async Task Build( try { - var docFs = DocumentationFileSystem.Resolve(path, new DocumentationScopeOptions - { - Output = options.Output?.FullName, - InnerWrite = writeFileSystem - }); + var docFs = DocumentationFileSystem.Resolve( + path, + new DocumentationScopeOptions { Output = options.Output?.FullName, InnerWrite = writeFileSystem } + ); context = new BuildContext(collector, docFs, configurationContext) { AvailableExporters = exporters, @@ -112,8 +111,10 @@ public async Task Build( // cause is a real bug — not the stale-merge-commit case this catch was written for — // the --git-dir remedy in e.Message actually reaches whoever is reading the failed run, // rather than being buried above a later, unrelated artifact-upload failure. - _logger.LogWarning("Skipping build on CI: {Message} If the docs folder is not actually out of date on a stale merge commit, this indicates a real path-resolution issue.", - e.Message); + _logger.LogWarning( + "Skipping build on CI: {Message} If the docs folder is not actually out of date on a stale merge commit, this indicates a real path-resolution issue.", + e.Message + ); await githubActionsService.SetOutputAsync("skip", "true"); return true; @@ -137,7 +138,8 @@ public async Task Build( var crossLinkFetcher = new DocSetConfigurationCrossLinkFetcher( logFactory, context.Configuration, - codexLinkIndexReader: codexReader); + codexLinkIndexReader: codexReader + ); var crossLinks = await crossLinkFetcher.FetchCrossLinks(ctx); IUriEnvironmentResolver? uriResolver = crossLinks.CodexRepositories is not null ? new CodexAwareUriResolver(crossLinks.CodexRepositories) @@ -158,15 +160,22 @@ public async Task Build( context.VersionsConfiguration, context.LegacyUrlMappings, set.Configuration, - context.Git); - var markdownExporters = exporters.CreateMarkdownExporters(logFactory, context, - branded: context.Configuration.Branding is not null); + context.Git + ); + var markdownExporters = exporters.CreateMarkdownExporters(logFactory, context, branded: context.Configuration.Branding is not null); var tasks = markdownExporters.Select(async e => await e.StartAsync(ctx)); await Task.WhenAll(tasks); - - var generator = new DocumentationGenerator(set, logFactory, set, null, null, markdownExporters.ToArray(), documentInferrer: documentInferrer); + var generator = new DocumentationGenerator( + set, + logFactory, + set, + null, + null, + markdownExporters.ToArray(), + documentInferrer: documentInferrer + ); _ = await generator.GenerateAll(ctx); if (!skipOpenApi) diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs index 2771b8f9ba..425f18faa4 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs @@ -33,14 +33,18 @@ public async Task Index( var cfg = _configurationContext.Endpoints.Elasticsearch; await ElasticsearchEndpointConfigurator.ApplyAsync(cfg, es, collector, fileSystem, ctx); - return await Build(collector, new IsolatedBuildOptions - { - Path = path != null ? new DirectoryInfo(path) : null, - MetadataOnly = true, - Strict = false, - Force = true, - SkipApi = true, - Exporters = new HashSet { Elasticsearch } - }, ctx: ctx); + return await Build( + collector, + new IsolatedBuildOptions + { + Path = path != null ? new DirectoryInfo(path) : null, + MetadataOnly = true, + Strict = false, + Force = true, + SkipApi = true, + Exporters = new HashSet { Elasticsearch } + }, + ctx: ctx + ); } } diff --git a/src/services/Elastic.Documentation.Services/ServiceInvoker.cs b/src/services/Elastic.Documentation.Services/ServiceInvoker.cs index 6d72437ff9..4eed63ab72 100644 --- a/src/services/Elastic.Documentation.Services/ServiceInvoker.cs +++ b/src/services/Elastic.Documentation.Services/ServiceInvoker.cs @@ -30,8 +30,11 @@ private static List EnsureReaderStarted(IDiagnosticsCollector c) return []; } - public void AddCommand(TService service, TState state, Func> invoke) - where TService : IService => + public void AddCommand( + TService service, + TState state, + Func> invoke + ) where TService : IService => _tasks.Add(new InvokeState { ServiceName = service.GetType().Name, @@ -39,8 +42,12 @@ public void AddCommand(TService service, TState state, Func await invoke(service, collector, state, ctx) }); - public void AddCommand(TService service, TState state, bool strict, Func> invoke) - where TService : IService => + public void AddCommand( + TService service, + TState state, + bool strict, + Func> invoke + ) where TService : IService => _tasks.Add(new InvokeState { ServiceName = service.GetType().Name, @@ -48,8 +55,10 @@ public void AddCommand(TService service, TState state, bool st Command = async ctx => await invoke(service, collector, state, ctx) }); - public void AddCommand(TService service, Func> invoke) - where TService : IService => + public void AddCommand( + TService service, + Func> invoke + ) where TService : IService => _tasks.Add(new InvokeState { ServiceName = service.GetType().Name, @@ -67,7 +76,9 @@ public async Task InvokeAsync(Cancel ctx) var success = await task.Command(ctx).ConfigureAwait(false); await collector.WaitForDrain(); if (!success && task.Strict && collector.Errors + collector.Warnings == 0) - collector.EmitGlobalError($"Service {task.ServiceName} registered as strict but returned false without emitting errors or warnings "); + collector.EmitGlobalError( + $"Service {task.ServiceName} registered as strict but returned false without emitting errors or warnings " + ); if (!success && !task.Strict && collector.Errors == 0) collector.EmitGlobalError($"Service {task.ServiceName} returned false without emitting errors"); } diff --git a/src/services/search/Elastic.Documentation.Search.Contract/Autocomplete/AutocompleteResponse.cs b/src/services/search/Elastic.Documentation.Search.Contract/Autocomplete/AutocompleteResponse.cs index a9c2c4148a..e39f0ad700 100644 --- a/src/services/search/Elastic.Documentation.Search.Contract/Autocomplete/AutocompleteResponse.cs +++ b/src/services/search/Elastic.Documentation.Search.Contract/Autocomplete/AutocompleteResponse.cs @@ -13,9 +13,7 @@ public record AutocompleteResponse where TDocument : SearchDocumentBa public required int PageSize { get; init; } public required AutocompleteAggregations Aggregations { get; init; } - public int PageCount => PageSize > 0 - ? (int)Math.Ceiling((double)TotalResults / PageSize) - : 0; + public int PageCount => PageSize > 0 ? (int)Math.Ceiling((double)TotalResults / PageSize) : 0; /// /// Time Elasticsearch spent processing the query, in milliseconds (the took field diff --git a/src/services/search/Elastic.Documentation.Search.Contract/Common/SearchDocumentBase.cs b/src/services/search/Elastic.Documentation.Search.Contract/Common/SearchDocumentBase.cs index 0931dcaf6c..ffad7544f8 100644 --- a/src/services/search/Elastic.Documentation.Search.Contract/Common/SearchDocumentBase.cs +++ b/src/services/search/Elastic.Documentation.Search.Contract/Common/SearchDocumentBase.cs @@ -137,9 +137,9 @@ public string ContentType [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? AiShortSummary { get; set; } - [AiField("3-8 keywords representing a realistic search query a user would type. Always include the " + - "relevant Elastic product/brand token (e.g. Elasticsearch, Kibana, Observability, Security) when " + - "the page is about a product concept, so brand-qualified queries like \"elasticsearch security\" prefix-match.")] + [AiField("3-8 keywords representing a realistic search query a user would type. Always include the " + + "relevant Elastic product/brand token (e.g. Elasticsearch, Kibana, Observability, Security) when " + + "the page is about a product concept, so brand-qualified queries like \"elasticsearch security\" prefix-match.")] [Keyword] [JsonPropertyName("ai_search_query")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] diff --git a/src/services/search/Elastic.Documentation.Search.Contract/Common/SearchDocumentPolymorphism.cs b/src/services/search/Elastic.Documentation.Search.Contract/Common/SearchDocumentPolymorphism.cs index 388f79d3ea..0792c135d7 100644 --- a/src/services/search/Elastic.Documentation.Search.Contract/Common/SearchDocumentPolymorphism.cs +++ b/src/services/search/Elastic.Documentation.Search.Contract/Common/SearchDocumentPolymorphism.cs @@ -133,11 +133,10 @@ public static Action WithFallback() => /// public static IJsonTypeInfoResolver Compose( IEnumerable consumerContexts, - params Action[] modifiers) + params Action[] modifiers + ) { - var resolvers = new[] { ContractResolver } - .Concat(consumerContexts) - .ToArray(); + var resolvers = new[] { ContractResolver }.Concat(consumerContexts).ToArray(); var combined = JsonTypeInfoResolver.Combine(resolvers); diff --git a/src/services/search/Elastic.Documentation.Search.Contract/Common/SharedAnalysisFactory.cs b/src/services/search/Elastic.Documentation.Search.Contract/Common/SharedAnalysisFactory.cs index 3a3eb5f861..078c9dc276 100644 --- a/src/services/search/Elastic.Documentation.Search.Contract/Common/SharedAnalysisFactory.cs +++ b/src/services/search/Elastic.Documentation.Search.Contract/Common/SharedAnalysisFactory.cs @@ -17,74 +17,73 @@ namespace Elastic.Documentation.Search.Contract.Mapping; /// public static class SharedAnalysisFactory { - public static AnalysisBuilder BuildBaseAnalysis(AnalysisBuilder analysis) => analysis - .Normalizer("keyword_normalizer", n => n.Custom() - .CharFilter("strip_non_word_chars") - .Filters("lowercase", "asciifolding", "trim")) - .Analyzer("starts_with_analyzer", a => a.Custom() - .Tokenizer("starts_with_tokenizer") - .Filter("lowercase")) - .Analyzer("starts_with_analyzer_search", a => a.Custom() - .Tokenizer("keyword") - .Filter("lowercase")) - .Analyzer("highlight_analyzer", a => a.Custom() - .Tokenizer("group_tokenizer") - .Filters("lowercase", "english_stop")) - .Analyzer("hierarchy_analyzer", a => a.Custom() - .Tokenizer("path_tokenizer")) - .CharFilter("strip_non_word_chars", cf => cf.PatternReplace() - .Pattern(@"\W") - .Replacement(" ")) - .TokenFilter("english_stop", tf => tf.Stop() - .Stopwords("_english_")) - .Tokenizer("starts_with_tokenizer", t => t.EdgeNGram() - .MinGram(1) - .MaxGram(10) - .TokenChars("letter", "digit", "symbol", "whitespace")) - .Tokenizer("group_tokenizer", t => t.CharGroup() - .TokenizeOnChars("whitespace", ",", ";", "?", "!", "(", ")", "&", "'", "\"", "/", "[", "]", "{", "}")) - .Tokenizer("path_tokenizer", t => t.PathHierarchy() - .Delimiter('/')) - // content_tags is populated purely via copy_to from content_type/navigation_section (both - // single-token keyword-ish values) — a keyword tokenizer preserves each value whole. kstem - // already folds regular plurals (labs -> lab, blogs -> blog, webinars -> webinar); the - // synonym filter only needs to cover what stemming can't: "customer-story" (hyphenated, - // stored form) vs "customer story" (the two-word phrase users actually type). - .Analyzer("content_tags_analyzer", a => a.Custom() - .Tokenizer("keyword") - .Filters("lowercase", "kstem", "content_tags_synonyms_filter")) - .TokenFilter("content_tags_synonyms_filter", tf => tf.SynonymGraph() - .Synonyms("customer story, customer-story")); + public static AnalysisBuilder BuildBaseAnalysis(AnalysisBuilder analysis) => + analysis.Normalizer( + "keyword_normalizer", + n => n.Custom().CharFilter("strip_non_word_chars").Filters("lowercase", "asciifolding", "trim") + ) + .Analyzer("starts_with_analyzer", a => a.Custom().Tokenizer("starts_with_tokenizer").Filter("lowercase")) + .Analyzer("starts_with_analyzer_search", a => a.Custom().Tokenizer("keyword").Filter("lowercase")) + .Analyzer("highlight_analyzer", a => a.Custom().Tokenizer("group_tokenizer").Filters("lowercase", "english_stop")) + .Analyzer("hierarchy_analyzer", a => a.Custom().Tokenizer("path_tokenizer")) + .CharFilter("strip_non_word_chars", cf => cf.PatternReplace().Pattern(@"\W").Replacement(" ")) + .TokenFilter("english_stop", tf => tf.Stop().Stopwords("_english_")) + .Tokenizer( + "starts_with_tokenizer", + t => t.EdgeNGram().MinGram(1).MaxGram(10).TokenChars("letter", "digit", "symbol", "whitespace") + ) + .Tokenizer( + "group_tokenizer", + t => t.CharGroup().TokenizeOnChars("whitespace", ",", ";", "?", "!", "(", ")", "&", "'", "\"", "/", "[", "]", "{", "}") + ) + .Tokenizer("path_tokenizer", t => t.PathHierarchy().Delimiter('/')) + // content_tags is populated purely via copy_to from content_type/navigation_section (both + // single-token keyword-ish values) — a keyword tokenizer preserves each value whole. kstem + // already folds regular plurals (labs -> lab, blogs -> blog, webinars -> webinar); the + // synonym filter only needs to cover what stemming can't: "customer-story" (hyphenated, + // stored form) vs "customer story" (the two-word phrase users actually type). + .Analyzer( + "content_tags_analyzer", + a => a.Custom().Tokenizer("keyword").Filters("lowercase", "kstem", "content_tags_synonyms_filter") + ) + .TokenFilter("content_tags_synonyms_filter", tf => tf.SynonymGraph().Synonyms("customer story, customer-story")); - public static AnalysisBuilder BuildAnalysis( - AnalysisBuilder analysis, - string synonymSetName, - string[] indexTimeSynonyms) => - BuildBaseAnalysis(analysis) - .Analyzer("synonyms_fixed_analyzer", a => a.Custom() - .CharFilter("symbol_rewrite_char_filter") - .Tokenizer("group_tokenizer") - .Filters("lowercase", "morphology_override_filter", "synonyms_fixed_filter", "kstem")) - .Analyzer("synonyms_analyzer", a => a.Custom() - .CharFilter("symbol_rewrite_char_filter") - .Tokenizer("group_tokenizer") - .Filters("lowercase", "morphology_override_filter", "synonyms_filter", "kstem")) - // Rewrite "c#" / standalone ".net" -> "dotnet" BEFORE tokenization: group_tokenizer never - // splits on "#" or ".", so the tokenizer would otherwise see one opaque "c#"/".net" token - // that can't match a plain "dotnet" query term. The negative lookbehind on ".net" avoids - // mangling compound tokens like "asp.net" (left untouched, same as today). - .CharFilter("symbol_rewrite_char_filter", cf => cf.PatternReplace() - .Pattern(@"(?i)\bc#|(? tf.StemmerOverride().Rules( - "config, configuration => config", - "install, installation => install", - "auth, authentication => auth")) - .TokenFilter("synonyms_fixed_filter", tf => tf.SynonymGraph() - .Synonyms(indexTimeSynonyms)) - .TokenFilter("synonyms_filter", tf => tf.SynonymGraph() - .SynonymsSet(synonymSetName) - .Updateable(true)); + public static AnalysisBuilder BuildAnalysis(AnalysisBuilder analysis, string synonymSetName, string[] indexTimeSynonyms) => + BuildBaseAnalysis(analysis).Analyzer( + "synonyms_fixed_analyzer", + a => + a.Custom() + .CharFilter("symbol_rewrite_char_filter") + .Tokenizer("group_tokenizer") + .Filters("lowercase", "morphology_override_filter", "synonyms_fixed_filter", "kstem") + ).Analyzer( + "synonyms_analyzer", + a => + a.Custom() + .CharFilter("symbol_rewrite_char_filter") + .Tokenizer("group_tokenizer") + .Filters("lowercase", "morphology_override_filter", "synonyms_filter", "kstem") + ) + // Rewrite "c#" / standalone ".net" -> "dotnet" BEFORE tokenization: group_tokenizer never + // splits on "#" or ".", so the tokenizer would otherwise see one opaque "c#"/".net" token + // that can't match a plain "dotnet" query term. The negative lookbehind on ".net" avoids + // mangling compound tokens like "asp.net" (left untouched, same as today). + .CharFilter( + "symbol_rewrite_char_filter", + cf => cf.PatternReplace().Pattern(@"(?i)\bc#|(? + tf.StemmerOverride().Rules( + "config, configuration => config", + "install, installation => install", + "auth, authentication => auth" + ) + ).TokenFilter("synonyms_fixed_filter", tf => tf.SynonymGraph().Synonyms(indexTimeSynonyms)).TokenFilter( + "synonyms_filter", + tf => tf.SynonymGraph().SynonymsSet(synonymSetName).Updateable(true) + ); } diff --git a/src/services/search/Elastic.Documentation.Search.Contract/Common/SharedMappingConfig.cs b/src/services/search/Elastic.Documentation.Search.Contract/Common/SharedMappingConfig.cs index 96aebe1bb7..96b244e860 100644 --- a/src/services/search/Elastic.Documentation.Search.Contract/Common/SharedMappingConfig.cs +++ b/src/services/search/Elastic.Documentation.Search.Contract/Common/SharedMappingConfig.cs @@ -25,146 +25,160 @@ public static class SharedMappingConfig public const string SynonymsAnalyzer = "synonyms_analyzer"; public const string ContentTagsAnalyzer = "content_tags_analyzer"; - private static MappingsBuilder AddCommonTitleMappings(this MappingsBuilder m) where T : SearchDocumentBase => m - .Title(f => f - .MultiField("keyword", mf => mf.Keyword().Normalizer(KeywordNormalizer)) - .MultiField("starts_with", mf => mf.Text() - .Analyzer(StartsWithAnalyzer) - .SearchAnalyzer(StartsWithAnalyzerSearch)) - .MultiField("completion", mf => mf.SearchAsYouType())) - .SearchTitle(f => f - .MultiField("completion", mf => mf.SearchAsYouType())) - .Path(f => f - .MultiField("match", mf => mf.Text()) - .MultiField("prefix", mf => mf.Text().Analyzer(HierarchyAnalyzer))); + private static MappingsBuilder AddCommonTitleMappings(this MappingsBuilder m) where T : SearchDocumentBase => + m.Title( + f => + f.MultiField("keyword", mf => mf.Keyword().Normalizer(KeywordNormalizer)) + .MultiField("starts_with", mf => mf.Text().Analyzer(StartsWithAnalyzer).SearchAnalyzer(StartsWithAnalyzerSearch)) + .MultiField("completion", mf => mf.SearchAsYouType()) + ) + .SearchTitle(f => f.MultiField("completion", mf => mf.SearchAsYouType())) + .Path(f => f.MultiField("match", mf => mf.Text()).MultiField("prefix", mf => mf.Text().Analyzer(HierarchyAnalyzer))); // Parents is declared on SearchDocumentBase, so every document type shares this topology — // keyword+multi-field on .path (breadcrumb-prefix search/exact-match) and a synonyms-aware // analyzer on .title. ParentDocument's own properties carry no [Keyword]/[Text] attributes, // so without this override every document type would fall back to a plain generator-default // text mapping with no multi-fields at all. - private static MappingsBuilder AddParentsFields(this MappingsBuilder m) where T : SearchDocumentBase => m + private static MappingsBuilder AddParentsFields(this MappingsBuilder m) where T : SearchDocumentBase => + m // parents is an object array — AddProperty places sub-fields under "properties". - .AddProperty("parents.path", f => f.Keyword() - .MultiField("match", mf => mf.Text()) - .MultiField("prefix", mf => mf.Text().Analyzer(HierarchyAnalyzer))) - .AddProperty("parents.title", f => f.Text() - .SearchAnalyzer(SynonymsAnalyzer) - .MultiField("keyword", mf => mf.Keyword())) - // Alias for the pre-rename field name — remove once all indices are rebuilt under the - // new `parents.path` shape and no consumer queries the old name. "parents" is an - // object array, so the alias sibling goes in its "properties" container (AddProperty), - // not "fields" (AddField requires a leaf-typed parent). - .AddProperty("parents.url", f => f.Alias("parents.path")); + .AddProperty( + "parents.path", + f => f.Keyword().MultiField("match", mf => mf.Text()).MultiField("prefix", mf => mf.Text().Analyzer(HierarchyAnalyzer)) + ) + .AddProperty("parents.title", f => f.Text().SearchAnalyzer(SynonymsAnalyzer).MultiField("keyword", mf => mf.Keyword())) + // Alias for the pre-rename field name — remove once all indices are rebuilt under the + // new `parents.path` shape and no consumer queries the old name. "parents" is an + // object array, so the alias sibling goes in its "properties" container (AddProperty), + // not "fields" (AddField requires a leaf-typed parent). + .AddProperty("parents.url", f => f.Alias("parents.path")); - private static MappingsBuilder AddNavigationFields(this MappingsBuilder m) where T : SearchDocumentBase => m - .Section(f => f.Normalizer(KeywordNormalizer).CopyTo("tags")) - .AddProperty("navigation.depth", f => f.RankFeature().PositiveScoreImpact(false)) - .AddProperty("navigation.table_of_contents", f => f.RankFeature().PositiveScoreImpact(false)) - .AiAutocompleteQuestions(f => f.MultiField("completion", mf => mf.SearchAsYouType())); + private static MappingsBuilder AddNavigationFields(this MappingsBuilder m) where T : SearchDocumentBase => + m.Section(f => f.Normalizer(KeywordNormalizer).CopyTo("tags")) + .AddProperty("navigation.depth", f => f.RankFeature().PositiveScoreImpact(false)) + .AddProperty("navigation.table_of_contents", f => f.RankFeature().PositiveScoreImpact(false)) + .AiAutocompleteQuestions(f => f.MultiField("completion", mf => mf.SearchAsYouType())); // content_type/section are set on every doc — copy_to routes both into a single tags field // so a query term naming a type/section ("labs", "blog", "api") rises by native BM25 with no // client-side term->type re-promotion rules. - private static MappingsBuilder AddContentTagsField(this MappingsBuilder m) where T : SearchDocumentBase => m - .ContentType(f => f.CopyTo("tags")) - .AddField("tags", f => f.Text().Analyzer(ContentTagsAnalyzer)); + private static MappingsBuilder AddContentTagsField(this MappingsBuilder m) where T : SearchDocumentBase => + m.ContentType(f => f.CopyTo("tags")).AddField("tags", f => f.Text().Analyzer(ContentTagsAnalyzer)); // ai_use_cases.semantic_text was dropped: no query anywhere (this repo's SearchQueryBuilder, // or website-search's DEFAULT_SEMANTIC_FIELDS) matches against it — ai_use_cases is only ever // read as a flat _source value (MCP get_doc/get_doc_structure), never queried semantically. // Removing it avoids paying embedding-inference cost per document for a subfield nothing queries. - private static MappingsBuilder AddSemanticFields(this MappingsBuilder m) where T : SearchDocumentBase => m + private static MappingsBuilder AddSemanticFields(this MappingsBuilder m) where T : SearchDocumentBase => + m // All parents are [Text] leaf fields — AddField places the semantic child under "fields" .AddField("title.semantic_text", f => f.SemanticText()) - .AddField("summary.semantic_text", f => f.SemanticText()) - .AddField("ai_rag_optimized_summary.semantic_text", f => f.SemanticText()) - .AddField("ai_questions.semantic_text", f => f.SemanticText()) - .AddField("body.semantic_text", f => f.SemanticText()); + .AddField("summary.semantic_text", f => f.SemanticText()) + .AddField("ai_rag_optimized_summary.semantic_text", f => f.SemanticText()) + .AddField("ai_questions.semantic_text", f => f.SemanticText()) + .AddField("body.semantic_text", f => f.SemanticText()); /// /// Elasticsearch alias fields for pre-restructure flat field names, so in-flight consumers /// (dashboards, saved searches, external queries) resolve during rollout without changes. /// Remove once all indices are rebuilt under the new shape and no consumer queries the old names. /// - private static MappingsBuilder AddLegacyFieldAliases(this MappingsBuilder m) where T : SearchDocumentBase => m - .AddField("abstract", f => f.Alias("summary")) - .AddField("navigation_section", f => f.Alias("section")) - .AddField("content_tags", f => f.Alias("tags")) - .AddField("stripped_body", f => f.Alias("body")) - .AddField("navigation_depth", f => f.Alias("navigation.depth")) - .AddField("navigation_table_of_contents", f => f.Alias("navigation.table_of_contents")); + private static MappingsBuilder AddLegacyFieldAliases(this MappingsBuilder m) where T : SearchDocumentBase => + m.AddField("abstract", f => f.Alias("summary")) + .AddField("navigation_section", f => f.Alias("section")) + .AddField("content_tags", f => f.Alias("tags")) + .AddField("stripped_body", f => f.Alias("body")) + .AddField("navigation_depth", f => f.Alias("navigation.depth")) + .AddField("navigation_table_of_contents", f => f.Alias("navigation.table_of_contents")); /// /// Full standard field set shared by Site, Labs, Guide, and WebsiteSearch lexical/semantic indices. /// Includes synonym-aware title/search_title, AI fields, multilingual body, and navigation rank features. /// Pass = true for semantic index variants. /// - public static MappingsBuilder AddSearchDocumentMappings(this MappingsBuilder m, bool semantic = false) where T : SearchDocumentBase + public static MappingsBuilder AddSearchDocumentMappings( + this MappingsBuilder m, + bool semantic = false + ) where T : SearchDocumentBase { - m = m - .AddNavigationFields() - .AddContentTagsField() - .AddCommonTitleMappings() - .AddParentsFields() - .AddLegacyFieldAliases() - .SearchTitle(f => f - .Analyzer(SynonymsFixedAnalyzer) - .SearchAnalyzer(SynonymsAnalyzer) - .MultiField("completion", mf => mf.SearchAsYouType() - .Analyzer(SynonymsFixedAnalyzer) - .SearchAnalyzer(SynonymsAnalyzer) - .IndexOptions("offsets"))) - .Title(f => f - .SearchAnalyzer(SynonymsAnalyzer) - .MultiField("keyword", mf => mf.Keyword().Normalizer(KeywordNormalizer)) - .MultiField("starts_with", mf => mf.Text() - .Analyzer(StartsWithAnalyzer) - .SearchAnalyzer(StartsWithAnalyzerSearch)) - .MultiField("completion", mf => mf.SearchAsYouType().SearchAnalyzer(SynonymsAnalyzer))) - .AiQuestions(f => f - .MultiField("completion", mf => mf.SearchAsYouType())) - // search_as_you_type only — no semantic_text here; this field is used by downstream typeahead. - .AiSearchQuery(f => f - .MultiField("completion", mf => mf.SearchAsYouType())) - .Summary(f => f - .Analyzer(SynonymsFixedAnalyzer) - .SearchAnalyzer(SynonymsAnalyzer)) - .Headings(f => f - .Analyzer(SynonymsFixedAnalyzer) - .SearchAnalyzer(SynonymsAnalyzer)) - .AiRagOptimizedSummary(f => f - .Analyzer(SynonymsFixedAnalyzer) - .SearchAnalyzer(SynonymsAnalyzer)) - .AiQuestions(f => f - .Analyzer(SynonymsFixedAnalyzer) - .SearchAnalyzer(SynonymsAnalyzer) - .MultiField("completion", mf => mf.SearchAsYouType() - .Analyzer(SynonymsFixedAnalyzer) - .SearchAnalyzer(SynonymsAnalyzer) - .IndexOptions("offsets"))) - .AiAutocompleteQuestions(f => f - .Analyzer(SynonymsFixedAnalyzer) - .SearchAnalyzer(SynonymsAnalyzer) - .MultiField("completion", mf => mf.SearchAsYouType() - .Analyzer(SynonymsFixedAnalyzer) - .SearchAnalyzer(SynonymsAnalyzer) - .IndexOptions("offsets")) - .MultiField("suggest", mf => mf.Completion())) - .Body(f => f - .Analyzer(SynonymsFixedAnalyzer) - .SearchAnalyzer(SynonymsAnalyzer) - .TermVector("with_positions_offsets") - .MultiField("en", mf => mf.Text().Analyzer(BuiltInAnalysis.Analyzers.Language.English)) - .MultiField("de", mf => mf.Text().Analyzer(BuiltInAnalysis.Analyzers.Language.German)) - .MultiField("fr", mf => mf.Text().Analyzer(BuiltInAnalysis.Analyzers.Language.French)) - .MultiField("ja", mf => mf.Text().Analyzer(BuiltInAnalysis.Analyzers.Language.Cjk)) - .MultiField("ko", mf => mf.Text().Analyzer(BuiltInAnalysis.Analyzers.Language.Cjk)) - .MultiField("zh", mf => mf.Text().Analyzer(BuiltInAnalysis.Analyzers.Language.Cjk)) - .MultiField("es", mf => mf.Text().Analyzer(BuiltInAnalysis.Analyzers.Language.Spanish)) - .MultiField("pt", mf => mf.Text().Analyzer(BuiltInAnalysis.Analyzers.Language.Portuguese))); + m = + m.AddNavigationFields() + .AddContentTagsField() + .AddCommonTitleMappings() + .AddParentsFields() + .AddLegacyFieldAliases() + .SearchTitle( + f => + f.Analyzer(SynonymsFixedAnalyzer) + .SearchAnalyzer(SynonymsAnalyzer) + .MultiField( + "completion", + mf => + mf.SearchAsYouType() + .Analyzer(SynonymsFixedAnalyzer) + .SearchAnalyzer(SynonymsAnalyzer) + .IndexOptions("offsets") + ) + ) + .Title( + f => + f.SearchAnalyzer(SynonymsAnalyzer) + .MultiField("keyword", mf => mf.Keyword().Normalizer(KeywordNormalizer)) + .MultiField( + "starts_with", + mf => mf.Text().Analyzer(StartsWithAnalyzer).SearchAnalyzer(StartsWithAnalyzerSearch) + ) + .MultiField("completion", mf => mf.SearchAsYouType().SearchAnalyzer(SynonymsAnalyzer)) + ) + .AiQuestions(f => f.MultiField("completion", mf => mf.SearchAsYouType())) + // search_as_you_type only — no semantic_text here; this field is used by downstream typeahead. + .AiSearchQuery(f => f.MultiField("completion", mf => mf.SearchAsYouType())) + .Summary(f => f.Analyzer(SynonymsFixedAnalyzer).SearchAnalyzer(SynonymsAnalyzer)) + .Headings(f => f.Analyzer(SynonymsFixedAnalyzer).SearchAnalyzer(SynonymsAnalyzer)) + .AiRagOptimizedSummary(f => f.Analyzer(SynonymsFixedAnalyzer).SearchAnalyzer(SynonymsAnalyzer)) + .AiQuestions( + f => + f.Analyzer(SynonymsFixedAnalyzer) + .SearchAnalyzer(SynonymsAnalyzer) + .MultiField( + "completion", + mf => + mf.SearchAsYouType() + .Analyzer(SynonymsFixedAnalyzer) + .SearchAnalyzer(SynonymsAnalyzer) + .IndexOptions("offsets") + ) + ) + .AiAutocompleteQuestions( + f => + f.Analyzer(SynonymsFixedAnalyzer) + .SearchAnalyzer(SynonymsAnalyzer) + .MultiField( + "completion", + mf => + mf.SearchAsYouType() + .Analyzer(SynonymsFixedAnalyzer) + .SearchAnalyzer(SynonymsAnalyzer) + .IndexOptions("offsets") + ) + .MultiField("suggest", mf => mf.Completion()) + ) + .Body( + f => + f.Analyzer(SynonymsFixedAnalyzer) + .SearchAnalyzer(SynonymsAnalyzer) + .TermVector("with_positions_offsets") + .MultiField("en", mf => mf.Text().Analyzer(BuiltInAnalysis.Analyzers.Language.English)) + .MultiField("de", mf => mf.Text().Analyzer(BuiltInAnalysis.Analyzers.Language.German)) + .MultiField("fr", mf => mf.Text().Analyzer(BuiltInAnalysis.Analyzers.Language.French)) + .MultiField("ja", mf => mf.Text().Analyzer(BuiltInAnalysis.Analyzers.Language.Cjk)) + .MultiField("ko", mf => mf.Text().Analyzer(BuiltInAnalysis.Analyzers.Language.Cjk)) + .MultiField("zh", mf => mf.Text().Analyzer(BuiltInAnalysis.Analyzers.Language.Cjk)) + .MultiField("es", mf => mf.Text().Analyzer(BuiltInAnalysis.Analyzers.Language.Spanish)) + .MultiField("pt", mf => mf.Text().Analyzer(BuiltInAnalysis.Analyzers.Language.Portuguese)) + ); return semantic ? m.AddSemanticFields() : m; } - } diff --git a/src/services/search/Elastic.Documentation.Search.Contract/Docs/DocumentationMappingConfig.cs b/src/services/search/Elastic.Documentation.Search.Contract/Docs/DocumentationMappingConfig.cs index 7bfb9d5deb..d019de2a8c 100644 --- a/src/services/search/Elastic.Documentation.Search.Contract/Docs/DocumentationMappingConfig.cs +++ b/src/services/search/Elastic.Documentation.Search.Contract/Docs/DocumentationMappingConfig.cs @@ -15,22 +15,9 @@ namespace Elastic.Documentation.Search.Contract; /// including analysis settings, mapping extensions, and AI enrichment configuration. /// [ElasticsearchMappingContext] -[Index( - NameTemplate = "docs-{type}.lexical-{env}", - DatePattern = "yyyy.MM.dd.HHmmss", - Configuration = typeof(DocumentationLexicalConfig) -)] -[Index( - NameTemplate = "docs-{type}.semantic-{env}", - Variant = "Semantic", - DatePattern = "yyyy.MM.dd.HHmmss", - Configuration = typeof(DocumentationSemanticConfig) -)] -[AiEnrichment( - Role = "Expert technical writer creating search metadata for Elastic documentation (Elasticsearch, Kibana, Beats, Logstash). Audience: developers, DevOps, data engineers.", - MatchField = "url", - IndexVariant = "Semantic" -)] +[Index(NameTemplate = "docs-{type}.lexical-{env}", DatePattern = "yyyy.MM.dd.HHmmss", Configuration = typeof(DocumentationLexicalConfig))] +[Index(NameTemplate = "docs-{type}.semantic-{env}", Variant = "Semantic", DatePattern = "yyyy.MM.dd.HHmmss", Configuration = typeof(DocumentationSemanticConfig))] +[AiEnrichment(Role = "Expert technical writer creating search metadata for Elastic documentation (Elasticsearch, Kibana, Beats, Logstash). Audience: developers, DevOps, data engineers.", MatchField = "url", IndexVariant = "Semantic")] public static partial class DocumentationMappingContext; public class DocumentationLexicalConfig : IConfigureElasticsearch diff --git a/src/services/search/Elastic.Documentation.Search.Contract/Docs/DocumentationMappingExtensions.cs b/src/services/search/Elastic.Documentation.Search.Contract/Docs/DocumentationMappingExtensions.cs index 01aadb0d09..44d7d52486 100644 --- a/src/services/search/Elastic.Documentation.Search.Contract/Docs/DocumentationMappingExtensions.cs +++ b/src/services/search/Elastic.Documentation.Search.Contract/Docs/DocumentationMappingExtensions.cs @@ -23,10 +23,10 @@ public static class DocumentationMappingExtensions /// public static MappingsBuilder AddDocumentationMappings(this MappingsBuilder m) => m - // applies_to is [Nested] — AddProperty places sub-fields under "properties". - // Note: AppliesToEntry properties have no [Keyword] attributes so the generated - // AppliesToEntryNestedBuilder pre-types them as Text; AddProperty lets us override to Keyword. - .AddProperty("applies_to.type", f => f.Keyword().Normalizer(SharedMappingConfig.KeywordNormalizer)) + // applies_to is [Nested] — AddProperty places sub-fields under "properties". + // Note: AppliesToEntry properties have no [Keyword] attributes so the generated + // AppliesToEntryNestedBuilder pre-types them as Text; AddProperty lets us override to Keyword. + .AddProperty("applies_to.type", f => f.Keyword().Normalizer(SharedMappingConfig.KeywordNormalizer)) .AddProperty("applies_to.sub_type", f => f.Keyword().Normalizer(SharedMappingConfig.KeywordNormalizer)) .AddProperty("applies_to.lifecycle", f => f.Keyword().Normalizer(SharedMappingConfig.KeywordNormalizer)) .AddProperty("applies_to.version", f => f.Version()); diff --git a/src/services/search/Elastic.Documentation.Search.Contract/Guide/GuideMappingConfig.cs b/src/services/search/Elastic.Documentation.Search.Contract/Guide/GuideMappingConfig.cs index 7f7d274c9a..2a36ebf5f1 100644 --- a/src/services/search/Elastic.Documentation.Search.Contract/Guide/GuideMappingConfig.cs +++ b/src/services/search/Elastic.Documentation.Search.Contract/Guide/GuideMappingConfig.cs @@ -10,22 +10,9 @@ namespace Elastic.Documentation.Search.Contract; [ElasticsearchMappingContext] -[Index( - NameTemplate = "guide-{type}.lexical-{env}", - DatePattern = "yyyy.MM.dd.HHmmss", - Configuration = typeof(GuideLexicalConfig) -)] -[Index( - NameTemplate = "guide-{type}.semantic-{env}", - Variant = "Semantic", - DatePattern = "yyyy.MM.dd.HHmmss", - Configuration = typeof(GuideSemanticConfig) -)] -[AiEnrichment( - Role = "Expert content analyst creating search metadata for Elastic's legacy /guide documentation pages.", - MatchField = "url", - IndexVariant = "Semantic" -)] +[Index(NameTemplate = "guide-{type}.lexical-{env}", DatePattern = "yyyy.MM.dd.HHmmss", Configuration = typeof(GuideLexicalConfig))] +[Index(NameTemplate = "guide-{type}.semantic-{env}", Variant = "Semantic", DatePattern = "yyyy.MM.dd.HHmmss", Configuration = typeof(GuideSemanticConfig))] +[AiEnrichment(Role = "Expert content analyst creating search metadata for Elastic's legacy /guide documentation pages.", MatchField = "url", IndexVariant = "Semantic")] public static partial class GuideMappingContext; public class GuideLexicalConfig : IConfigureElasticsearch @@ -53,8 +40,7 @@ public static class GuideMappingExtensions { public static MappingsBuilder AddGuideMappings(this MappingsBuilder m) => m - // Aliases for pre-nesting field names — remove once all indices are rebuilt under the - // new `http.*` shape and no consumer queries the old names. - .AddField("http_etag", f => f.Alias("http.etag")) - .AddField("http_last_modified", f => f.Alias("http.last_modified")); + // Aliases for pre-nesting field names — remove once all indices are rebuilt under the + // new `http.*` shape and no consumer queries the old names. + .AddField("http_etag", f => f.Alias("http.etag")).AddField("http_last_modified", f => f.Alias("http.last_modified")); } diff --git a/src/services/search/Elastic.Documentation.Search.Contract/Labs/LabsMappingConfig.cs b/src/services/search/Elastic.Documentation.Search.Contract/Labs/LabsMappingConfig.cs index 144984fd2a..4b52f8fa78 100644 --- a/src/services/search/Elastic.Documentation.Search.Contract/Labs/LabsMappingConfig.cs +++ b/src/services/search/Elastic.Documentation.Search.Contract/Labs/LabsMappingConfig.cs @@ -10,22 +10,9 @@ namespace Elastic.Documentation.Search.Contract; [ElasticsearchMappingContext] -[Index( - NameTemplate = "labs-{type}.lexical-{env}", - DatePattern = "yyyy.MM.dd.HHmmss", - Configuration = typeof(LabsLexicalConfig) -)] -[Index( - NameTemplate = "labs-{type}.semantic-{env}", - Variant = "Semantic", - DatePattern = "yyyy.MM.dd.HHmmss", - Configuration = typeof(LabsSemanticConfig) -)] -[AiEnrichment( - Role = "Expert content analyst creating search metadata for Elastic's website pages (blogs, labs articles, product pages, events). Audience: developers, DevOps engineers, security analysts, and IT decision-makers.", - MatchField = "url", - IndexVariant = "Semantic" -)] +[Index(NameTemplate = "labs-{type}.lexical-{env}", DatePattern = "yyyy.MM.dd.HHmmss", Configuration = typeof(LabsLexicalConfig))] +[Index(NameTemplate = "labs-{type}.semantic-{env}", Variant = "Semantic", DatePattern = "yyyy.MM.dd.HHmmss", Configuration = typeof(LabsSemanticConfig))] +[AiEnrichment(Role = "Expert content analyst creating search metadata for Elastic's website pages (blogs, labs articles, product pages, events). Audience: developers, DevOps engineers, security analysts, and IT decision-makers.", MatchField = "url", IndexVariant = "Semantic")] public static partial class LabsMappingContext; public class LabsLexicalConfig : IConfigureElasticsearch diff --git a/src/services/search/Elastic.Documentation.Search.Contract/Search/SearchAggregations.cs b/src/services/search/Elastic.Documentation.Search.Contract/Search/SearchAggregations.cs index efe06f4b73..43c8742ac7 100644 --- a/src/services/search/Elastic.Documentation.Search.Contract/Search/SearchAggregations.cs +++ b/src/services/search/Elastic.Documentation.Search.Contract/Search/SearchAggregations.cs @@ -10,6 +10,9 @@ public record SearchAggregations public IReadOnlyDictionary Type { get; init; } = new Dictionary(); public IReadOnlyDictionary NavigationSection { get; init; } = new Dictionary(); public IReadOnlyDictionary DeploymentType { get; init; } = new Dictionary(); - public IReadOnlyDictionary Product { get; init; } = - new Dictionary(); + public IReadOnlyDictionary Product + { + get; + init; + } = new Dictionary(); } diff --git a/src/services/search/Elastic.Documentation.Search.Contract/Search/SearchQueryComponents.cs b/src/services/search/Elastic.Documentation.Search.Contract/Search/SearchQueryComponents.cs index 65f6282c0d..0ed37c2830 100644 --- a/src/services/search/Elastic.Documentation.Search.Contract/Search/SearchQueryComponents.cs +++ b/src/services/search/Elastic.Documentation.Search.Contract/Search/SearchQueryComponents.cs @@ -21,78 +21,79 @@ public enum SearchQueryComponents : uint // --- Lexical sub-clauses (bits 0–9) --- /// Bool-prefix multimatch on search_title.completion* (boost 3). - Completion = 1 << 0, // 1 + Completion = 1 << 0, // 1 /// Best-fields multimatch on stripped_body (boost 0.1). - Body = 1 << 1, // 2 + Body = 1 << 1, // 2 /// Term on title.keyword (lowercased), with synonym expansion. - TitleKeyword = 1 << 2, // 4 + TitleKeyword = 1 << 2, // 4 /// ConstantScore TermQuery on title.starts_with (queries ≤ 10 chars). TitleStartsWith = 1 << 3, // 8 /// ConstantScore MatchQuery on url.match (single-token queries only). - UrlMatch = 1 << 4, // 16 + UrlMatch = 1 << 4, // 16 /// Phrase multimatch on stripped_body (3+ token queries only). - Phrase = 1 << 5, // 32 + Phrase = 1 << 5, // 32 /// BoolQuery MustNot filter: exclude bare /docs roots and hidden docs. - DocumentFilter = 1 << 6, // 64 + DocumentFilter = 1 << 6, // 64 /// 2× RankFeatureQuery (nav depth/toc) + 2× TermQuery section boosts in should. - Scoring = 1 << 7, // 128 + Scoring = 1 << 7, // 128 /// BoostingQuery wrapping: negatively boosts documents matching diminish terms. - Diminish = 1 << 8, // 256 + Diminish = 1 << 8, // 256 /// RuleQuery wrap using the configured ruleset. - Rules = 1 << 9, // 512 + Rules = 1 << 9, // 512 // --- Semantic sub-clauses (bits 10–13; each = one kNN inference call) --- /// SemanticQuery on title.semantic_text (boost 5). - SemTitle = 1 << 10, // 1024 + SemTitle = 1 << 10, // 1024 /// SemanticQuery on abstract.semantic_text (boost 3). - SemAbstract = 1 << 11, // 2048 + SemAbstract = 1 << 11, // 2048 /// SemanticQuery on ai_rag_optimized_summary.semantic_text (boost 4). - SemRag = 1 << 12, // 4096 + SemRag = 1 << 12, // 4096 /// SemanticQuery on ai_questions.semantic_text (boost 2). - SemQuestions = 1 << 13, // 8192 + SemQuestions = 1 << 13, // 8192 // --- Request-level ES features (bits 14–18) --- /// Terms aggregation on content_type. - AggType = 1 << 14, // 16384 + AggType = 1 << 14, // 16384 /// Terms aggregation on navigation_section. - AggSection = 1 << 15, // 32768 + AggSection = 1 << 15, // 32768 /// Terms aggregation on related_products.id (size 100). - AggProduct = 1 << 16, // 65536 + AggProduct = 1 << 16, // 65536 /// Highlight block on title + stripped_body. - Highlight = 1 << 17, // 131072 + Highlight = 1 << 17, // 131072 /// Honor the SortBy field (Recent/Alpha); omitting reverts to score order. - Sort = 1 << 18, // 262144 + Sort = 1 << 18, // 262144 // --- Composites --- /// All 10 lexical sub-clauses combined. - Lexical = Completion | Body | TitleKeyword | TitleStartsWith | UrlMatch | Phrase - | DocumentFilter | Scoring | Diminish | Rules, // 1023 + Lexical = Completion | Body | TitleKeyword | TitleStartsWith | UrlMatch | Phrase | DocumentFilter | Scoring | Diminish | Rules, // 1023 /// All 4 semantic sub-clauses combined (each triggers a separate kNN inference call). - Semantic = SemTitle | SemAbstract | SemRag | SemQuestions, // 15360 + Semantic = SemTitle | SemAbstract | SemRag | SemQuestions, // 15360 /// All 3 aggregations. - Aggregations = AggType | AggSection | AggProduct, // 114688 + Aggregations = AggType | AggSection | AggProduct, // 114688 /// Every clause on — equivalent to the production query (subject to per-clause gating). - All = (1u << 19) - 1 // 524287 + All = (1u << 19) - + 1 // 524287 + } diff --git a/src/services/search/Elastic.Documentation.Search.Contract/Search/SearchResponse.cs b/src/services/search/Elastic.Documentation.Search.Contract/Search/SearchResponse.cs index 4dd4f88b59..16779bf387 100644 --- a/src/services/search/Elastic.Documentation.Search.Contract/Search/SearchResponse.cs +++ b/src/services/search/Elastic.Documentation.Search.Contract/Search/SearchResponse.cs @@ -34,7 +34,5 @@ public record SearchResponse where TDocument : SearchDocumentBase /// public bool IsValidResponse { get; init; } - public int PageCount => PageSize > 0 - ? (int)Math.Ceiling((double)TotalResults / PageSize) - : 0; + public int PageCount => PageSize > 0 ? (int)Math.Ceiling((double)TotalResults / PageSize) : 0; } diff --git a/src/services/search/Elastic.Documentation.Search.Contract/Site/SiteMappingConfig.cs b/src/services/search/Elastic.Documentation.Search.Contract/Site/SiteMappingConfig.cs index b3bc79f8b7..e02bd087fd 100644 --- a/src/services/search/Elastic.Documentation.Search.Contract/Site/SiteMappingConfig.cs +++ b/src/services/search/Elastic.Documentation.Search.Contract/Site/SiteMappingConfig.cs @@ -10,22 +10,9 @@ namespace Elastic.Documentation.Search.Contract; [ElasticsearchMappingContext] -[Index( - NameTemplate = "site-{type}.lexical-{env}", - DatePattern = "yyyy.MM.dd.HHmmss", - Configuration = typeof(SiteLexicalConfig) -)] -[Index( - NameTemplate = "site-{type}.semantic-{env}", - Variant = "Semantic", - DatePattern = "yyyy.MM.dd.HHmmss", - Configuration = typeof(SiteSemanticConfig) -)] -[AiEnrichment( - Role = "Expert content analyst creating search metadata for Elastic's website pages (blogs, labs articles, product pages, events). Audience: developers, DevOps engineers, security analysts, and IT decision-makers.", - MatchField = "url", - IndexVariant = "Semantic" -)] +[Index(NameTemplate = "site-{type}.lexical-{env}", DatePattern = "yyyy.MM.dd.HHmmss", Configuration = typeof(SiteLexicalConfig))] +[Index(NameTemplate = "site-{type}.semantic-{env}", Variant = "Semantic", DatePattern = "yyyy.MM.dd.HHmmss", Configuration = typeof(SiteSemanticConfig))] +[AiEnrichment(Role = "Expert content analyst creating search metadata for Elastic's website pages (blogs, labs articles, product pages, events). Audience: developers, DevOps engineers, security analysts, and IT decision-makers.", MatchField = "url", IndexVariant = "Semantic")] public static partial class SiteMappingContext; public class SiteLexicalConfig : IConfigureElasticsearch @@ -57,9 +44,9 @@ public static class SiteMappingExtensions { public static MappingsBuilder AddSiteMappings(this MappingsBuilder m) where T : SiteDocument => m - // Aliases for pre-nesting/pre-rename field names — remove once all indices are rebuilt - // under the new `og.*`/`twitter.*`/`http.*`/`locale` shape and no consumer queries the old names. - .AddField("language", f => f.Alias("locale")) + // Aliases for pre-nesting/pre-rename field names — remove once all indices are rebuilt + // under the new `og.*`/`twitter.*`/`http.*`/`locale` shape and no consumer queries the old names. + .AddField("language", f => f.Alias("locale")) .AddField("og_title", f => f.Alias("og.title")) .AddField("og_description", f => f.Alias("og.description")) .AddField("og_image", f => f.Alias("og.image")) diff --git a/src/services/search/Elastic.Documentation.Search.Contract/WebsiteSearch/WebsiteSearchMappingConfig.cs b/src/services/search/Elastic.Documentation.Search.Contract/WebsiteSearch/WebsiteSearchMappingConfig.cs index 2ea3ee431c..67b0397efd 100644 --- a/src/services/search/Elastic.Documentation.Search.Contract/WebsiteSearch/WebsiteSearchMappingConfig.cs +++ b/src/services/search/Elastic.Documentation.Search.Contract/WebsiteSearch/WebsiteSearchMappingConfig.cs @@ -10,17 +10,8 @@ namespace Elastic.Documentation.Search.Contract; [ElasticsearchMappingContext] -[Index( - NameTemplate = "ws-catalog.lexical-{env}", - DatePattern = "yyyy.MM.dd.HHmmss", - Configuration = typeof(WebsiteSearchLexicalConfig) -)] -[Index( - NameTemplate = "ws-catalog.semantic-{env}", - Variant = "Semantic", - DatePattern = "yyyy.MM.dd.HHmmss", - Configuration = typeof(WebsiteSearchSemanticConfig) -)] +[Index(NameTemplate = "ws-catalog.lexical-{env}", DatePattern = "yyyy.MM.dd.HHmmss", Configuration = typeof(WebsiteSearchLexicalConfig))] +[Index(NameTemplate = "ws-catalog.semantic-{env}", Variant = "Semantic", DatePattern = "yyyy.MM.dd.HHmmss", Configuration = typeof(WebsiteSearchSemanticConfig))] public static partial class WebsiteSearchMappingContext; public class WebsiteSearchLexicalConfig : IConfigureElasticsearch @@ -30,7 +21,8 @@ public class WebsiteSearchLexicalConfig : IConfigureElasticsearch? IndexSettings => null; public MappingsBuilder ConfigureMappings(MappingsBuilder mappings) => - mappings.AddSearchDocumentMappings().AddSiteMappings() + mappings.AddSearchDocumentMappings() + .AddSiteMappings() // WebsiteSearchDocument has no C# property for applies_to (DocumentationDocument-only) and // its inherited parents field lacks the docs-specific keyword/multi-field topology — merge // DocumentationDocument's mapping (additive-only; existing fields here always win) so docs-* @@ -46,6 +38,7 @@ public class WebsiteSearchSemanticConfig : IConfigureElasticsearch? IndexSettings => null; public MappingsBuilder ConfigureMappings(MappingsBuilder mappings) => - mappings.AddSearchDocumentMappings(semantic: true).AddSiteMappings() + mappings.AddSearchDocumentMappings(semantic: true) + .AddSiteMappings() .Merge(DocumentationMappingContext.DocumentationDocumentSemantic, d => d.AddDocumentationMappings()); } diff --git a/src/services/search/Elastic.Documentation.Search/ChangesService.cs b/src/services/search/Elastic.Documentation.Search/ChangesService.cs index d6e41b1701..0a9ef53a35 100644 --- a/src/services/search/Elastic.Documentation.Search/ChangesService.cs +++ b/src/services/search/Elastic.Documentation.Search/ChangesService.cs @@ -29,29 +29,17 @@ public async Task GetChangesAsync(ChangesRequest request, Cance var cursor = DecodeCursor(request.Cursor); var pageSize = Math.Clamp(request.PageSize, 1, ChangesDefaults.MaxPageSize); - var internalRequest = new ChangesInternalRequest - { - Since = request.Since, - PageSize = pageSize, - Cursor = cursor - }; + var internalRequest = new ChangesInternalRequest { Since = request.Since, PageSize = pageSize, Cursor = cursor }; var result = await GetChangesInternalAsync(internalRequest, ctx); - var nextCursor = result.NextCursor is not null - ? EncodeCursor(result.NextCursor) - : null; + var nextCursor = result.NextCursor is not null ? EncodeCursor(result.NextCursor) : null; var hasMore = nextCursor is not null; LogChanges(logger, request.Since, result.Pages.Count, hasMore); - return new ChangesResponse - { - Pages = result.Pages, - HasMore = hasMore, - NextCursor = nextCursor - }; + return new ChangesResponse { Pages = result.Pages, HasMore = hasMore, NextCursor = nextCursor }; } private async Task GetChangesInternalAsync(ChangesInternalRequest request, Cancel ctx = default) @@ -90,68 +78,58 @@ private async Task GetChangesInternalAsync(ChangesInternalRequest } } - private async Task Search( - ChangesInternalRequest request, string pitId, int fetchSize, Cancel ctx - ) => - await clientAccessor.Client.SearchAsync(s => - { - _ = s - .Size(fetchSize) - .TrackTotalHits(t => t.Enabled(false)) - .Pit(p => p.Id(pitId).KeepAlive(SharedPointInTimeManager.PitKeepAlive)) - .Query(q => q.Range(r => r - .Date(dr => dr - .Field(f => f.ContentLastUpdated) - .Gt(request.Since.ToString("O")) - ) - )) - .Sort( - so => so.Field(f => f.ContentLastUpdated, sf => sf.Order(SortOrder.Asc)), - so => so.Field(f => f.Path, sf => sf.Order(SortOrder.Asc)) - ) - .Source(sf => sf - .Filter(f => f - .Includes( - e => e.Path, - e => e.Title, - e => e.SearchTitle, - e => e.ContentType, - e => e.ContentLastUpdated - ) - ) - ); - - if (request.Cursor is { } cursor) + private async Task Search(ChangesInternalRequest request, string pitId, int fetchSize, Cancel ctx) => + await clientAccessor.Client.SearchAsync( + s => { - _ = s.SearchAfter( - FieldValue.Long(cursor.ContentLastUpdatedEpochMs), - FieldValue.String(cursor.Url) - ); - } - }, ctx); + _ = + s.Size(fetchSize) + .TrackTotalHits(t => t.Enabled(false)) + .Pit(p => p.Id(pitId).KeepAlive(SharedPointInTimeManager.PitKeepAlive)) + .Query(q => q.Range(r => r.Date(dr => dr.Field(f => f.ContentLastUpdated).Gt(request.Since.ToString("O"))))) + .Sort( + so => so.Field(f => f.ContentLastUpdated, sf => sf.Order(SortOrder.Asc)), + so => so.Field(f => f.Path, sf => sf.Order(SortOrder.Asc)) + ) + .Source( + sf => + sf.Filter( + f => + f.Includes( + e => e.Path, + e => e.Title, + e => e.SearchTitle, + e => e.ContentType, + e => e.ContentLastUpdated + ) + ) + ); + + if (request.Cursor is { } cursor) + { + _ = s.SearchAfter(FieldValue.Long(cursor.ContentLastUpdatedEpochMs), FieldValue.String(cursor.Url)); + } + }, + ctx + ); private static bool IsExpiredPit(EsSearchResponse response) => response.ElasticsearchServerError?.Error?.Type is "search_phase_execution_exception" - || response.ElasticsearchServerError?.Error?.Reason?.Contains("point in time", StringComparison.OrdinalIgnoreCase) == true - || response.ElasticsearchServerError?.Error?.Reason?.Contains("No search context found", StringComparison.OrdinalIgnoreCase) == true; + || response.ElasticsearchServerError?.Error?.Reason?.Contains("point in time", StringComparison.OrdinalIgnoreCase) == true + || response.ElasticsearchServerError?.Error?.Reason?.Contains("No search context found", StringComparison.OrdinalIgnoreCase) == + true; private static ChangesResult BuildResult(EsSearchResponse response, int pageSize) { var hits = response.Hits.ToList(); var hasMore = hits.Count > pageSize; - var pages = hits - .Take(pageSize) + var pages = hits.Take(pageSize) .Where(h => h.Source is not null) .Select(h => { var doc = h.Source!; - return new ChangedPageDto - { - Url = doc.Path, - Title = doc.Title, - LastUpdated = doc.ContentLastUpdated - }; + return new ChangedPageDto { Url = doc.Path, Title = doc.Title, LastUpdated = doc.ContentLastUpdated }; }) .ToList(); @@ -165,20 +143,16 @@ private static ChangesResult BuildResult(EsSearchResponse response, int pageSize var sortUrl = lastHit.Sort.ElementAt(1); // ES returns date sort values as double (JSON has no int/float distinction) - var epochMs = sortEpoch.TryGetLong(out var l) ? l!.Value - : sortEpoch.TryGetDouble(out var d) ? (long)d!.Value - : default(long?); + var epochMs = sortEpoch.TryGetLong(out var l) + ? l!.Value + : sortEpoch.TryGetDouble(out var d) ? (long)d!.Value : default(long?); if (epochMs is not null && sortUrl.TryGetString(out var url)) nextCursor = new ChangesPageCursor(epochMs.Value, url!); } } - return new ChangesResult - { - Pages = pages, - NextCursor = nextCursor - }; + return new ChangesResult { Pages = pages, NextCursor = nextCursor }; } private static ChangesPageCursor? DecodeCursor(string? cursor) @@ -190,10 +164,7 @@ private static ChangesResult BuildResult(EsSearchResponse response, int pageSize { var remainder = cursor.Length % 4; var paddingLength = (4 - remainder) % 4; - var base64 = cursor - .Replace('-', '+') - .Replace('_', '/') - + new string('=', paddingLength); + var base64 = cursor.Replace('-', '+').Replace('_', '/') + new string('=', paddingLength); var json = Encoding.UTF8.GetString(Convert.FromBase64String(base64)); using var doc = JsonDocument.Parse(json); @@ -225,14 +196,10 @@ private static string EncodeCursor(ChangesPageCursor cursor) writer.WriteEndArray(); writer.Flush(); - return Convert.ToBase64String(buffer.WrittenSpan) - .TrimEnd('=') - .Replace('+', '-') - .Replace('/', '_'); + return Convert.ToBase64String(buffer.WrittenSpan).TrimEnd('=').Replace('+', '-').Replace('/', '_'); } - [LoggerMessage(Level = LogLevel.Information, - Message = "Changes feed returned {Count} pages since {Since} (hasMore: {HasMore})")] + [LoggerMessage(Level = LogLevel.Information, Message = "Changes feed returned {Count} pages since {Since} (hasMore: {HasMore})")] private static partial void LogChanges(ILogger logger, DateTimeOffset since, int count, bool hasMore); [LoggerMessage(Level = LogLevel.Warning, Message = "PIT expired or not found, opening a new one and retrying with existing search_after position")] diff --git a/src/services/search/Elastic.Documentation.Search/Common/ElasticsearchClientAccessor.cs b/src/services/search/Elastic.Documentation.Search/Common/ElasticsearchClientAccessor.cs index 3147be1960..f32e20421c 100644 --- a/src/services/search/Elastic.Documentation.Search/Common/ElasticsearchClientAccessor.cs +++ b/src/services/search/Elastic.Documentation.Search/Common/ElasticsearchClientAccessor.cs @@ -35,10 +35,7 @@ public class ElasticsearchClientAccessor : IDisposable public IReadOnlyDictionary SynonymBiDirectional { get; } public IReadOnlyCollection DiminishTerms { get; } - public ElasticsearchClientAccessor( - DocumentationEndpoints endpoints, - SearchConfiguration searchConfiguration - ) + public ElasticsearchClientAccessor(DocumentationEndpoints endpoints, SearchConfiguration searchConfiguration) { var endpoint = endpoints.Elasticsearch; Endpoint = endpoint; @@ -50,37 +47,29 @@ SearchConfiguration searchConfiguration .CreateContext(type: endpoints.BuildType, env: endpoints.Environment) .ResolveReadTarget(); - SearchIndex = !string.IsNullOrEmpty(endpoints.SearchIndexOverride) - ? endpoints.SearchIndexOverride - : computedIndex; + SearchIndex = !string.IsNullOrEmpty(endpoints.SearchIndexOverride) ? endpoints.SearchIndexOverride : computedIndex; - RulesetName = searchConfiguration.Rules.Count > 0 - ? $"docs-ruleset-{endpoints.BuildType}-{endpoints.Environment}" - : null; + RulesetName = searchConfiguration.Rules.Count > 0 ? $"docs-ruleset-{endpoints.BuildType}-{endpoints.Environment}" : null; _nodePool = new SingleNodePool(endpoint.Uri); var auth = endpoint.ApiKey is { } apiKey ? (AuthorizationHeader)new ApiKey(apiKey) - : endpoint is { Username: { } username, Password: { } password } - ? new BasicAuthentication(username, password) - : null!; + : endpoint is { Username: { } username, Password: { } password } ? new BasicAuthentication(username, password) : null!; - _clientSettings = new ElasticsearchClientSettings( + _clientSettings = + new ElasticsearchClientSettings( _nodePool, - sourceSerializer: (_, settings) => new DefaultSourceSerializer( - settings, - ElasticsearchClientJsonResolver.Default - ) - ) - .DefaultIndex(SearchIndex) - .Authentication(auth) - // Unlimited connections so a load surge actually reaches the (serverless) cluster and lets it - // autoscale, instead of self-throttling to the transport default (80) and parking requests in - // connection-pool wait. The RequestTimeout below is what bounds peak in-flight sockets. - .ConnectionLimit(-1) - // Fail-fast: the transport default (60s) lets a cold/slow query hang and hold a connection; - // 20s sheds genuinely stuck requests while still allowing for serverless cold-start latency. - .RequestTimeout(TimeSpan.FromSeconds(20)); + sourceSerializer: (_, settings) => new DefaultSourceSerializer(settings, ElasticsearchClientJsonResolver.Default) + ) + .DefaultIndex(SearchIndex) + .Authentication(auth) + // Unlimited connections so a load surge actually reaches the (serverless) cluster and lets it + // autoscale, instead of self-throttling to the transport default (80) and parking requests in + // connection-pool wait. The RequestTimeout below is what bounds peak in-flight sockets. + .ConnectionLimit(-1) + // Fail-fast: the transport default (60s) lets a cold/slow query hang and hold a connection; + // 20s sheds genuinely stuck requests while still allowing for serverless cold-start latency. + .RequestTimeout(TimeSpan.FromSeconds(20)); Client = new ElasticsearchClient(_clientSettings); } diff --git a/src/services/search/Elastic.Documentation.Search/Common/ElasticsearchClientJsonResolver.cs b/src/services/search/Elastic.Documentation.Search/Common/ElasticsearchClientJsonResolver.cs index 9fbc28335c..098ed86404 100644 --- a/src/services/search/Elastic.Documentation.Search/Common/ElasticsearchClientJsonResolver.cs +++ b/src/services/search/Elastic.Documentation.Search/Common/ElasticsearchClientJsonResolver.cs @@ -27,10 +27,7 @@ internal static class ElasticsearchClientJsonResolver private static IJsonTypeInfoResolver Create() => SearchDocumentPolymorphism.Compose( - consumerContexts: - [ - QuerySerializationContext.Default, - ], + consumerContexts: [QuerySerializationContext.Default,], SearchDocumentPolymorphism.WithFallback() ); } diff --git a/src/services/search/Elastic.Documentation.Search/Configuration/SearchQueryConfiguration.cs b/src/services/search/Elastic.Documentation.Search/Configuration/SearchQueryConfiguration.cs index c11c339122..88773172d4 100644 --- a/src/services/search/Elastic.Documentation.Search/Configuration/SearchQueryConfiguration.cs +++ b/src/services/search/Elastic.Documentation.Search/Configuration/SearchQueryConfiguration.cs @@ -11,8 +11,7 @@ namespace Elastic.Documentation.Search; /// public sealed record SearchQueryConfiguration { - public IReadOnlyDictionary SynonymBiDirectional { get; init; } = - new Dictionary(); + public IReadOnlyDictionary SynonymBiDirectional { get; init; } = new Dictionary(); public IReadOnlyCollection DiminishTerms { get; init; } = []; diff --git a/src/services/search/Elastic.Documentation.Search/DefaultSearchService.cs b/src/services/search/Elastic.Documentation.Search/DefaultSearchService.cs index 51eedda178..e612ec1f0a 100644 --- a/src/services/search/Elastic.Documentation.Search/DefaultSearchService.cs +++ b/src/services/search/Elastic.Documentation.Search/DefaultSearchService.cs @@ -25,29 +25,45 @@ public partial class DefaultSearchService( string indexAlias, SearchQueryConfiguration searchConfig, ILogger> logger, - IProductNameLookup? productNameLookup = null) - : ISearchService - where TDocument : SearchDocumentBase + IProductNameLookup? productNameLookup = null +) : ISearchService where TDocument : SearchDocumentBase { private const string PreTag = ""; private const string PostTag = ""; private static readonly string[] AutocompleteSourceIncludes = [ - "content_type", "title", "search_title", "path", "description", "parents", "headings" + "content_type", + "title", + "search_title", + "path", + "description", + "parents", + "headings" ]; private static readonly string[] SearchSourceIncludes = [ - "content_type", "title", "search_title", "path", "description", "parents", "headings", - "section", "ai_short_summary", "ai_rag_optimized_summary", - "last_updated", "product", "related_products" + "content_type", + "title", + "search_title", + "path", + "description", + "parents", + "headings", + "section", + "ai_short_summary", + "ai_rag_optimized_summary", + "last_updated", + "product", + "related_products" ]; private static readonly Regex SemanticKeywordsRegex = BuildSemanticKeywordsRegex(); private static readonly Regex ExcludeFromHighlightRegex = BuildExcludeFromHighlightRegex(); - [GeneratedRegex(@"^(how|why|what|when|where|can|should|is it|do i|does|will|would|could)", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + [GeneratedRegex(@"^(how|why|what|when|where|can|should|is it|do i|does|will|would|could)", RegexOptions.IgnoreCase | + RegexOptions.Compiled)] private static partial Regex BuildSemanticKeywordsRegex(); [GeneratedRegex(@"^(how|why|what|when|where|can|should|is|it|do|i|does|will|would|could)$", RegexOptions.IgnoreCase)] @@ -90,55 +106,73 @@ public async Task> AutocompleteAsync(Autocomplet request.Query, searchConfig.SynonymBiDirectional, searchConfig.DiminishTerms, - searchConfig.RulesetName); + searchConfig.RulesetName + ); Query? postFilter = null; if (!string.IsNullOrWhiteSpace(request.TypeFilter)) postFilter = new TermQuery { Field = QueryFieldNames.ContentType, Value = request.TypeFilter }; - var response = await client.SearchAsync(s => - { - _ = s - .Indices(indexAlias) - .From(Math.Max(request.PageNumber - 1, 0) * request.PageSize) - .Size(request.PageSize) - .Query(lexicalQuery) - .Aggregations(agg => agg - .Add("type", a => a.Terms(t => t.Field(QueryFieldNames.ContentType)))) - .Source(sf => sf.Filter(f => f.Includes(AutocompleteSourceIncludes))) - .Highlight(h => h - .Fields(f => f - .Add(QueryFieldNames.Title, hf => hf - .FragmentSize(150) - .NumberOfFragments(3) - .NoMatchSize(150) - .HighlightQuery(q => q.Match(m => m - .Field(QueryFieldNames.Title) - .Query(request.Query) - .Analyzer("highlight_analyzer"))) - .PreTags(PreTag) - .PostTags(PostTag)) - .Add(QueryFieldNames.Body, hf => hf - .FragmentSize(150) - .NumberOfFragments(3) - .NoMatchSize(150) - .PreTags(PreTag) - .PostTags(PostTag)))); - - if (postFilter is not null) - _ = s.PostFilter(postFilter); - }, ct); + var response = + await client.SearchAsync( + s => + { + _ = + s.Indices(indexAlias) + .From(Math.Max(request.PageNumber - 1, 0) * request.PageSize) + .Size(request.PageSize) + .Query(lexicalQuery) + .Aggregations(agg => agg.Add("type", a => a.Terms(t => t.Field(QueryFieldNames.ContentType)))) + .Source(sf => sf.Filter(f => f.Includes(AutocompleteSourceIncludes))) + .Highlight( + h => + h.Fields( + f => + f.Add( + QueryFieldNames.Title, + hf => + hf.FragmentSize(150) + .NumberOfFragments(3) + .NoMatchSize(150) + .HighlightQuery( + q => + q.Match( + m => + m.Field(QueryFieldNames.Title) + .Query(request.Query) + .Analyzer("highlight_analyzer") + ) + ) + .PreTags(PreTag) + .PostTags(PostTag) + ).Add( + QueryFieldNames.Body, + hf => + hf.FragmentSize(150) + .NumberOfFragments(3) + .NoMatchSize(150) + .PreTags(PreTag) + .PostTags(PostTag) + ) + ) + ); + + if (postFilter is not null) + _ = s.PostFilter(postFilter); + }, + ct + ); if (!response.IsValidResponse) LogInvalidResponse(response.ElasticsearchServerError?.Error?.Reason ?? "Unknown"); - var results = response.Hits.Select(hit => SearchResultProcessor - .ProcessHit(hit, request.Query, searchConfig.SynonymBiDirectional)).ToList(); + var results = response.Hits + .Select(hit => SearchResultProcessor.ProcessHit(hit, request.Query, searchConfig.SynonymBiDirectional)) + .ToList(); var typeAgg = SearchResultProcessor.ExtractTermsAggregation(response, "type"); - LogAutocompleteResults(request.PageSize, request.PageNumber, request.Query, - results.Select(r => r.Document.Path).ToArray()); + LogAutocompleteResults(request.PageSize, request.PageNumber, request.Query, results.Select(r => r.Document.Path).ToArray()); // NOTE: ElasticsearchTookMs and IsValidResponse are available on the in-repo contract — restore in a follow-up. return new AutocompleteResponse @@ -162,80 +196,109 @@ public async Task> AutocompleteAsync(Autocomplet request.Query, searchConfig.SynonymBiDirectional, searchConfig.DiminishTerms, - searchConfig.RulesetName); + searchConfig.RulesetName + ); var baseQuery = isSemantic - ? new BoolQuery - { - Should = [lexicalQuery, SearchQueryBuilder.BuildSemanticQuery(request.Query)], - MinimumShouldMatch = 1 - } + ? new BoolQuery { Should = [lexicalQuery, SearchQueryBuilder.BuildSemanticQuery(request.Query)], MinimumShouldMatch = 1 } : lexicalQuery; var filteredQuery = ApplyFilters(baseQuery, request); - var response2 = await client.SearchAsync(s => - { - _ = s - .Indices(indexAlias) - .From(Math.Max(request.PageNumber - 1, 0) * request.PageSize) - .Size(request.PageSize) - .Query(filteredQuery) - .Aggregations(agg => agg - .Add("type", a => a.Terms(t => t.Field(QueryFieldNames.ContentType))) - .Add("navigation_section", a => a.Terms(t => t.Field(QueryFieldNames.Section))) - .Add("product", a => a.Terms(t => t.Field(QueryFieldNames.RelatedProductsId).Size(100)))) - .Source(sf => sf.Filter(f => f.Includes(SearchSourceIncludes))); - - if (request.IncludeHighlighting) - { - _ = s.Highlight(h => h - .Fields(f => f - .Add(QueryFieldNames.Title, hf => hf - .FragmentSize(150) - .NumberOfFragments(3) - .NoMatchSize(150) - .HighlightQuery(q => q.Match(m => m - .Field(QueryFieldNames.Title) - .Query(request.Query) - .Analyzer("highlight_analyzer"))) - .PreTags(PreTag) - .PostTags(PostTag)) - .Add(QueryFieldNames.Body, hf => hf - .FragmentSize(150) - .NumberOfFragments(3) - .NoMatchSize(150) - .PreTags(PreTag) - .PostTags(PostTag)))); - } - - ApplySorting(s, request.SortBy); - }, ct); + var response2 = + await client.SearchAsync( + s => + { + _ = + s.Indices(indexAlias) + .From(Math.Max(request.PageNumber - 1, 0) * request.PageSize) + .Size(request.PageSize) + .Query(filteredQuery) + .Aggregations( + agg => + agg.Add("type", a => a.Terms(t => t.Field(QueryFieldNames.ContentType))) + .Add("navigation_section", a => a.Terms(t => t.Field(QueryFieldNames.Section))) + .Add("product", a => a.Terms(t => t.Field(QueryFieldNames.RelatedProductsId).Size(100))) + ) + .Source(sf => sf.Filter(f => f.Includes(SearchSourceIncludes))); + + if (request.IncludeHighlighting) + { + _ = + s.Highlight( + h => + h.Fields( + f => + f.Add( + QueryFieldNames.Title, + hf => + hf.FragmentSize(150) + .NumberOfFragments(3) + .NoMatchSize(150) + .HighlightQuery( + q => + q.Match( + m => + m.Field(QueryFieldNames.Title) + .Query(request.Query) + .Analyzer("highlight_analyzer") + ) + ) + .PreTags(PreTag) + .PostTags(PostTag) + ).Add( + QueryFieldNames.Body, + hf => + hf.FragmentSize(150) + .NumberOfFragments(3) + .NoMatchSize(150) + .PreTags(PreTag) + .PostTags(PostTag) + ) + ) + ); + } + + ApplySorting(s, request.SortBy); + }, + ct + ); if (!response2.IsValidResponse) LogInvalidResponse(response2.ElasticsearchServerError?.Error?.Reason ?? "Unknown"); var highlightOptions = request.IncludeHighlighting ? FullPageHighlightOptions : null; - var results2 = response2.Hits.Select(hit => SearchResultProcessor - .ProcessHit(hit, request.IncludeHighlighting ? request.Query : string.Empty, - searchConfig.SynonymBiDirectional, highlightOptions)).ToList(); + var results2 = response2.Hits + .Select( + hit => + SearchResultProcessor.ProcessHit( + hit, + request.IncludeHighlighting ? request.Query : string.Empty, + searchConfig.SynonymBiDirectional, + highlightOptions + ) + ) + .ToList(); var aggregations2 = new SearchAggregations { Type = SearchResultProcessor.ExtractTermsAggregation(response2, "type"), NavigationSection = SearchResultProcessor.ExtractTermsAggregation(response2, "navigation_section"), - Product = SearchResultProcessor.ExtractTermsAggregation(response2, "product") - .ToDictionary(kvp => kvp.Key, kvp => new Contract.ProductAggregationBucket - { - Count = kvp.Value, - DisplayName = productNameLookup is not null && productNameLookup.TryGetProductName(kvp.Key, out var name) - ? name - : null - }) + Product = + SearchResultProcessor.ExtractTermsAggregation(response2, "product").ToDictionary( + kvp => kvp.Key, + kvp => + new Contract.ProductAggregationBucket + { + Count = kvp.Value, + DisplayName = productNameLookup is not null && productNameLookup.TryGetProductName(kvp.Key, out var name) + ? name + : null + } + ) }; - LogSearchResults(request.PageSize, request.PageNumber, request.Query, isSemantic, - results2.Select(r => r.Document.Path).ToArray()); + LogSearchResults(request.PageSize, request.PageNumber, request.Query, isSemantic, results2.Select(r => r.Document.Path).ToArray()); // NOTE: ElasticsearchTookMs and IsValidResponse are available on the in-repo contract — restore in a follow-up. return new Contract.SearchResponse @@ -262,16 +325,16 @@ private static Query ApplyFilters(Query baseQuery, SearchRequest request) if (request.TypeFilter is { Length: > 0 }) { - filters.Add(new TermsQuery( - QueryFieldNames.ContentType, - new TermsQueryField(request.TypeFilter.Select(t => (FieldValue)t).ToArray()))); + filters.Add( + new TermsQuery(QueryFieldNames.ContentType, new TermsQueryField(request.TypeFilter.Select(t => (FieldValue)t).ToArray())) + ); } if (request.SectionFilter is { Length: > 0 }) { - filters.Add(new TermsQuery( - QueryFieldNames.Section, - new TermsQueryField(request.SectionFilter.Select(s => (FieldValue)s).ToArray()))); + filters.Add( + new TermsQuery(QueryFieldNames.Section, new TermsQueryField(request.SectionFilter.Select(s => (FieldValue)s).ToArray())) + ); } // AND semantics — each requested product must match. @@ -286,11 +349,7 @@ private static Query ApplyFilters(Query baseQuery, SearchRequest request) if (filters.Count == 0) return baseQuery; - return new BoolQuery - { - Must = [baseQuery], - Filter = filters - }; + return new BoolQuery { Must = [baseQuery], Filter = filters }; } private static void ApplySorting(SearchRequestDescriptor descriptor, SortMode sortBy) diff --git a/src/services/search/Elastic.Documentation.Search/Diagnostics/SearchExplainExtensions.cs b/src/services/search/Elastic.Documentation.Search/Diagnostics/SearchExplainExtensions.cs index 5f1b1ba91c..4c94f109d4 100644 --- a/src/services/search/Elastic.Documentation.Search/Diagnostics/SearchExplainExtensions.cs +++ b/src/services/search/Elastic.Documentation.Search/Diagnostics/SearchExplainExtensions.cs @@ -20,25 +20,25 @@ public static async Task ExplainDocumentAsync( this DefaultSearchService service, string query, string documentUrl, - CancellationToken ct = default) - where TDocument : SearchDocumentBase + CancellationToken ct = default + ) where TDocument : SearchDocumentBase { var lexicalQuery = SearchQueryBuilder.BuildLexicalQuery( query, service.Configuration.SynonymBiDirectional, service.Configuration.DiminishTerms, - service.Configuration.RulesetName); + service.Configuration.RulesetName + ); - var combinedQuery = (Query)new BoolQuery - { - Should = [lexicalQuery], - MinimumShouldMatch = 1 - }; + var combinedQuery = (Query)new BoolQuery { Should = [lexicalQuery], MinimumShouldMatch = 1 }; - var getDocResponse = await service.Client.SearchAsync(s => s - .Indices(service.IndexAlias) - .Query(q => q.Term(t => t.Field("url").Value(documentUrl))) - .Size(1), ct).ConfigureAwait(false); + var getDocResponse = + await service.Client + .SearchAsync( + s => s.Indices(service.IndexAlias).Query(q => q.Term(t => t.Field("url").Value(documentUrl))).Size(1), + ct + ) + .ConfigureAwait(false); if (!getDocResponse.IsValidResponse || getDocResponse.Documents.Count == 0) { @@ -53,8 +53,10 @@ public static async Task ExplainDocumentAsync( var documentId = getDocResponse.Hits.First().Id; - var explainResponse = await service.Client.ExplainAsync( - service.IndexAlias, documentId, e => e.Query(combinedQuery), ct).ConfigureAwait(false); + var explainResponse = + await service.Client + .ExplainAsync(service.IndexAlias, documentId, e => e.Query(combinedQuery), ct) + .ConfigureAwait(false); if (!explainResponse.IsValidResponse) { @@ -83,10 +85,13 @@ public static async Task ExplainDocumentAsync( this DefaultSearchService service, string query, string expectedDocumentUrl, - CancellationToken ct = default) - where TDocument : SearchDocumentBase + CancellationToken ct = default + ) where TDocument : SearchDocumentBase { - var top = await service.AutocompleteAsync(new AutocompleteRequest { Query = query, PageNumber = 1, PageSize = 1 }, ct).ConfigureAwait(false); + var top = + await service.AutocompleteAsync(new AutocompleteRequest { Query = query, PageNumber = 1, PageSize = 1 }, ct).ConfigureAwait( + false + ); var topResultUrl = top.Results.Count > 0 ? top.Results[0].Document.Path : null; if (string.IsNullOrEmpty(topResultUrl)) diff --git a/src/services/search/Elastic.Documentation.Search/FullSearchService.cs b/src/services/search/Elastic.Documentation.Search/FullSearchService.cs index ff14b9c5f0..20345b94d9 100644 --- a/src/services/search/Elastic.Documentation.Search/FullSearchService.cs +++ b/src/services/search/Elastic.Documentation.Search/FullSearchService.cs @@ -19,32 +19,36 @@ namespace Elastic.Documentation.Search; public partial class FullSearchService( ISearchService inner, IProductNameLookup productNameLookup, - ILogger logger) - : IFullSearchService, IDisposable + ILogger logger +) : IFullSearchService, IDisposable { public async Task SearchAsync(FullSearchRequest request, Cancel ctx = default) { SearchResponse resp; try { - resp = await inner.SearchAsync(new SearchRequest - { - Query = request.Query, - PageNumber = request.PageNumber, - PageSize = request.PageSize, - TypeFilter = request.TypeFilter ?? [], - SectionFilter = request.SectionFilter ?? [], - ProductFilter = request.ProductFilter ?? [], - DeploymentFilter = request.DeploymentFilter ?? [], - VersionFilter = request.VersionFilter, - SortBy = request.SortBy.ToLowerInvariant() switch - { - "recent" => SortMode.Recent, - "alpha" => SortMode.Alpha, - _ => SortMode.Relevance - }, - IncludeHighlighting = request.IncludeHighlighting - }, ctx); + resp = + await inner.SearchAsync( + new SearchRequest + { + Query = request.Query, + PageNumber = request.PageNumber, + PageSize = request.PageSize, + TypeFilter = request.TypeFilter ?? [], + SectionFilter = request.SectionFilter ?? [], + ProductFilter = request.ProductFilter ?? [], + DeploymentFilter = request.DeploymentFilter ?? [], + VersionFilter = request.VersionFilter, + SortBy = request.SortBy.ToLowerInvariant() switch + { + "recent" => SortMode.Recent, + "alpha" => SortMode.Alpha, + _ => SortMode.Relevance + }, + IncludeHighlighting = request.IncludeHighlighting + }, + ctx + ); } catch (TransportException ex) when (IsTransient(ex)) { @@ -52,7 +56,8 @@ public async Task SearchAsync(FullSearchRequest request, Can // so callers (e.g. MCP tools) can signal "retry in a few seconds" to their clients. throw new SearchUnavailableException( $"Search backend is temporarily unavailable ({ex.FailureReason}). Transient — retry in a few seconds.", - ex); + ex + ); } var response = new FullSearchResponse @@ -71,7 +76,8 @@ public async Task SearchAsync(FullSearchRequest request, Can response.PageNumber, request.Query, response.IsSemanticQuery, - response.Results.Select(i => i.Url).ToArray()); + response.Results.Select(i => i.Url).ToArray() + ); return response; } @@ -79,57 +85,53 @@ public async Task SearchAsync(FullSearchRequest request, Can // True for transport failures that are inherently transient: request timeout, retry exhaustion // on a single-node pool, or server-side overload (HTTP 429 / 503). private static bool IsTransient(TransportException ex) => - ex.FailureReason is PipelineFailure.MaxTimeoutReached or PipelineFailure.MaxRetriesReached - || (ex.FailureReason is PipelineFailure.BadResponse - && ex.ApiCallDetails?.HttpStatusCode is 429 or 503); + ex.FailureReason is PipelineFailure.MaxTimeoutReached or PipelineFailure.MaxRetriesReached || + (ex.FailureReason is PipelineFailure.BadResponse && ex.ApiCallDetails?.HttpStatusCode is 429 or 503); [LoggerMessage(Level = LogLevel.Information, Message = "Full search completed with {PageSize} (page {PageNumber}) results for query '{SearchQuery}' (semantic: {IsSemantic}): {Urls}")] private static partial void LogFullSearchResults( - ILogger logger, int pageSize, int pageNumber, string searchQuery, bool isSemantic, string[] urls); + ILogger logger, + int pageSize, + int pageNumber, + string searchQuery, + bool isSemantic, + string[] urls + ); - private FullSearchResultItem MapHit(SearchResultItem item) => new() - { - Type = item.Document.ContentType, - Url = item.Document.Path, - Title = item.Title, - Description = item.Description, - Parents = (item.Document.Parents ?? []) - .Select(p => new FullSearchResultParent { Title = p.Title, Url = p.Path }) - .ToArray(), - Score = item.Score, - AiShortSummary = item.Document.AiShortSummary, - AiRagOptimizedSummary = item.Document.AiRagOptimizedSummary, - NavigationSection = item.Document.Section, - LastUpdated = item.Document.LastUpdated, - Product = MapProduct(item.Document.Product), - RelatedProducts = (item.Document.RelatedProducts? - .Where(p => p.Id is not null) - .Select(p => MapProduct(p.Id)!) - .ToArray()) ?? [] - }; + private FullSearchResultItem MapHit(SearchResultItem item) => + new() + { + Type = item.Document.ContentType, + Url = item.Document.Path, + Title = item.Title, + Description = item.Description, + Parents = (item.Document.Parents ?? []).Select(p => new FullSearchResultParent { Title = p.Title, Url = p.Path }).ToArray(), + Score = item.Score, + AiShortSummary = item.Document.AiShortSummary, + AiRagOptimizedSummary = item.Document.AiRagOptimizedSummary, + NavigationSection = item.Document.Section, + LastUpdated = item.Document.LastUpdated, + Product = MapProduct(item.Document.Product), + RelatedProducts = (item.Document.RelatedProducts?.Where(p => p.Id is not null).Select(p => MapProduct(p.Id)!).ToArray()) ?? [] + }; private FullSearchProduct? MapProduct(string? id) => id is not null - ? new FullSearchProduct - { - Id = id, - DisplayName = productNameLookup.TryGetProductName(id, out var name) ? name : id - } + ? new FullSearchProduct { Id = id, DisplayName = productNameLookup.TryGetProductName(id, out var name) ? name : id } : null; - private FullSearchAggregations MapAggregations(SearchAggregations agg) => new() - { - Type = agg.Type, - NavigationSection = agg.NavigationSection, - DeploymentType = agg.DeploymentType, - Product = agg.Product.ToDictionary( - kvp => kvp.Key, - kvp => new ProductAggregationBucket - { - Count = kvp.Value.Count, - DisplayName = kvp.Value.DisplayName ?? kvp.Key - }) - }; + private FullSearchAggregations MapAggregations(SearchAggregations agg) => + new() + { + Type = agg.Type, + NavigationSection = agg.NavigationSection, + DeploymentType = agg.DeploymentType, + Product = + agg.Product.ToDictionary( + kvp => kvp.Key, + kvp => new ProductAggregationBucket { Count = kvp.Value.Count, DisplayName = kvp.Value.DisplayName ?? kvp.Key } + ) + }; public void Dispose() => GC.SuppressFinalize(this); } diff --git a/src/services/search/Elastic.Documentation.Search/Highlighting/SearchResultProcessor.cs b/src/services/search/Elastic.Documentation.Search/Highlighting/SearchResultProcessor.cs index d6f60e8440..4812662433 100644 --- a/src/services/search/Elastic.Documentation.Search/Highlighting/SearchResultProcessor.cs +++ b/src/services/search/Elastic.Documentation.Search/Highlighting/SearchResultProcessor.cs @@ -18,14 +18,13 @@ public static SearchResultItem ProcessHit( Hit hit, string searchQuery, IReadOnlyDictionary synonyms, - HighlightOptions? highlightOptions = null) - where TDocument : SearchDocumentBase + HighlightOptions? highlightOptions = null + ) where TDocument : SearchDocumentBase { var options = highlightOptions ?? HighlightOptions.Default; var doc = hit.Source!; var highlights = hit.Highlight; - var searchTokens = searchQuery - .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + var searchTokens = searchQuery.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Where(token => token.Length >= options.MinTokenLength) .Where(token => options.ExcludePattern is null || !options.ExcludePattern.IsMatch(token)) .ToArray(); @@ -61,7 +60,8 @@ public static SearchResultItem ProcessHit( public static IReadOnlyDictionary ExtractTermsAggregation( Clients.Elasticsearch.SearchResponse response, - string aggregationName) + string aggregationName + ) { var aggregations = new Dictionary(); var terms = response.Aggregations?.GetStringTerms(aggregationName); @@ -76,7 +76,8 @@ public static IReadOnlyDictionary ExtractTermsAggregation ExtractNestedTermsAggregation( Clients.Elasticsearch.SearchResponse response, string nestedAggregationName, - string innerTermsAggregationName) + string innerTermsAggregationName + ) { var aggregations = new Dictionary(); var nested = response.Aggregations?.GetNested(nestedAggregationName); diff --git a/src/services/search/Elastic.Documentation.Search/Highlighting/StringHighlightExtensions.cs b/src/services/search/Elastic.Documentation.Search/Highlighting/StringHighlightExtensions.cs index 731b351ef4..aed5435e77 100644 --- a/src/services/search/Elastic.Documentation.Search/Highlighting/StringHighlightExtensions.cs +++ b/src/services/search/Elastic.Documentation.Search/Highlighting/StringHighlightExtensions.cs @@ -21,7 +21,8 @@ public static string HighlightTokens( this string text, ReadOnlySpan tokens, IReadOnlyDictionary? synonyms = null, - bool wholeWordOnly = false) + bool wholeWordOnly = false + ) { if (tokens.Length == 0 || string.IsNullOrEmpty(text)) return text; @@ -56,9 +57,11 @@ public static string HighlightTokens( continue; var (source, target) = ParseHardReplacement(synonym); - if (!string.IsNullOrEmpty(source) && - !string.IsNullOrEmpty(target) && - source.Equals(token, StringComparison.OrdinalIgnoreCase)) + if ( + !string.IsNullOrEmpty(source) + && !string.IsNullOrEmpty(target) + && source.Equals(token, StringComparison.OrdinalIgnoreCase) + ) { result = HighlightSingleToken(result, target, wholeWordOnly); } @@ -137,10 +140,7 @@ private static string HighlightSingleToken(string text, string token, bool whole continue; } - _ = sb.Append(remaining[..matchIndex]) - .Append(MarkOpen) - .Append(remaining.Slice(matchIndex, tokenSpan.Length)) - .Append(MarkClose); + _ = sb.Append(remaining[..matchIndex]).Append(MarkOpen).Append(remaining.Slice(matchIndex, tokenSpan.Length)).Append(MarkClose); pos = absoluteIndex + token.Length; } diff --git a/src/services/search/Elastic.Documentation.Search/IFullSearchService.cs b/src/services/search/Elastic.Documentation.Search/IFullSearchService.cs index 59af09bd69..608d5e149b 100644 --- a/src/services/search/Elastic.Documentation.Search/IFullSearchService.cs +++ b/src/services/search/Elastic.Documentation.Search/IFullSearchService.cs @@ -22,11 +22,11 @@ public record FullSearchRequest public int PageNumber { get; init; } = 1; public int PageSize { get; init; } = 20; public string[]? TypeFilter { get; init; } - public string[]? SectionFilter { get; init; } // navigation_section - public string[]? DeploymentFilter { get; init; } // applies_to.type - public string[]? ProductFilter { get; init; } // product.id (AND behavior) - public string? VersionFilter { get; init; } // "9.0+" | "8.19" | "7.17" - public string SortBy { get; init; } = "relevance"; // relevance | recent | alpha + public string[]? SectionFilter { get; init; } // navigation_section + public string[]? DeploymentFilter { get; init; } // applies_to.type + public string[]? ProductFilter { get; init; } // product.id (AND behavior) + public string? VersionFilter { get; init; } // "9.0+" | "8.19" | "7.17" + public string SortBy { get; init; } = "relevance"; // relevance | recent | alpha public bool IncludeHighlighting { get; init; } = true; } @@ -41,9 +41,7 @@ public record FullSearchResponse public required int PageSize { get; init; } public FullSearchAggregations Aggregations { get; init; } = new(); public bool IsSemanticQuery { get; init; } - public int PageCount => TotalResults > 0 - ? (int)Math.Ceiling((double)TotalResults / PageSize) - : 0; + public int PageCount => TotalResults > 0 ? (int)Math.Ceiling((double)TotalResults / PageSize) : 0; } /// @@ -65,7 +63,11 @@ public record FullSearchAggregations public IReadOnlyDictionary Type { get; init; } = new Dictionary(); public IReadOnlyDictionary NavigationSection { get; init; } = new Dictionary(); public IReadOnlyDictionary DeploymentType { get; init; } = new Dictionary(); - public IReadOnlyDictionary Product { get; init; } = new Dictionary(); + public IReadOnlyDictionary Product + { + get; + init; + } = new Dictionary(); } /// diff --git a/src/services/search/Elastic.Documentation.Search/INavigationSearchService.cs b/src/services/search/Elastic.Documentation.Search/INavigationSearchService.cs index 9fdcf6feb3..bc18e57b31 100644 --- a/src/services/search/Elastic.Documentation.Search/INavigationSearchService.cs +++ b/src/services/search/Elastic.Documentation.Search/INavigationSearchService.cs @@ -24,9 +24,7 @@ public record NavigationSearchResponse public required int PageNumber { get; init; } public required int PageSize { get; init; } public NavigationSearchAggregations Aggregations { get; init; } = new(); - public int PageCount => TotalResults > 0 - ? (int)Math.Ceiling((double)TotalResults / PageSize) - : 0; + public int PageCount => TotalResults > 0 ? (int)Math.Ceiling((double)TotalResults / PageSize) : 0; } public record NavigationSearchAggregations diff --git a/src/services/search/Elastic.Documentation.Search/MockSearchService.cs b/src/services/search/Elastic.Documentation.Search/MockSearchService.cs index a3dffb9672..c973d353fb 100644 --- a/src/services/search/Elastic.Documentation.Search/MockSearchService.cs +++ b/src/services/search/Elastic.Documentation.Search/MockSearchService.cs @@ -22,7 +22,8 @@ public class MockNavigationSearchService : INavigationSearchService Type = "doc", Url = "https://www.elastic.co/docs/explore-analyze", Title = "Explore and analyze | Elastic Docs", - Description = "Kibana provides a comprehensive suite of tools to help you search, interact with, explore, and analyze your data effectively.", + Description = + "Kibana provides a comprehensive suite of tools to help you search, interact with, explore, and analyze your data effectively.", Parents = [] }, new NavigationSearchResultItem @@ -66,7 +67,8 @@ public class MockNavigationSearchService : INavigationSearchService Type = "doc", Url = "https://www.elastic.co/docs/solutions/search/elasticsearch-basics-quickstart", Title = "Elasticsearch basics quickstart", - Description = "Hands-on introduction to fundamental Elasticsearch concepts: indices, documents, mappings, and search via Console syntax.", + Description = + "Hands-on introduction to fundamental Elasticsearch concepts: indices, documents, mappings, and search via Console syntax.", Parents = [] }, new NavigationSearchResultItem @@ -82,28 +84,24 @@ public class MockNavigationSearchService : INavigationSearchService public async Task NavigationSearchAsync(NavigationSearchRequest request, CancellationToken ctx = default) { - var filteredResults = Results - .Where(item => + var filteredResults = Results.Where( + item => item.Title.Contains(request.Query, StringComparison.OrdinalIgnoreCase) || - item.Description?.Contains(request.Query, StringComparison.OrdinalIgnoreCase) == true) - .ToList(); + item.Description?.Contains(request.Query, StringComparison.OrdinalIgnoreCase) == true + ).ToList(); // Apply type filter if specified if (!string.IsNullOrWhiteSpace(request.TypeFilter)) filteredResults = filteredResults.Where(item => item.Type == request.TypeFilter).ToList(); // Calculate aggregations before filtering - var aggregations = Results - .Where(item => + var aggregations = Results.Where( + item => item.Title.Contains(request.Query, StringComparison.OrdinalIgnoreCase) || - item.Description?.Contains(request.Query, StringComparison.OrdinalIgnoreCase) == true) - .GroupBy(item => item.Type) - .ToDictionary(g => g.Key, g => (long)g.Count()); + item.Description?.Contains(request.Query, StringComparison.OrdinalIgnoreCase) == true + ).GroupBy(item => item.Type).ToDictionary(g => g.Key, g => (long)g.Count()); - var pagedResults = filteredResults - .Skip((request.PageNumber - 1) * request.PageSize) - .Take(request.PageSize) - .ToList(); + var pagedResults = filteredResults.Skip((request.PageNumber - 1) * request.PageSize).Take(request.PageSize).ToList(); Console.WriteLine($"MockSearchService: Paged results count: {pagedResults.Count}"); diff --git a/src/services/search/Elastic.Documentation.Search/NavigationSearchService.cs b/src/services/search/Elastic.Documentation.Search/NavigationSearchService.cs index e1d4926af6..7bb1c89328 100644 --- a/src/services/search/Elastic.Documentation.Search/NavigationSearchService.cs +++ b/src/services/search/Elastic.Documentation.Search/NavigationSearchService.cs @@ -19,20 +19,24 @@ namespace Elastic.Documentation.Search; public partial class NavigationSearchService( ISearchService inner, ElasticsearchClientAccessor clientAccessor, - ILogger logger) - : INavigationSearchService, IDisposable + ILogger logger +) : INavigationSearchService, IDisposable { public async Task CanConnect(Cancel ctx) => await clientAccessor.CanConnect(ctx); public async Task NavigationSearchAsync(NavigationSearchRequest request, Cancel ctx = default) { - var resp = await inner.AutocompleteAsync(new AutocompleteRequest - { - Query = request.Query, - PageNumber = request.PageNumber, - PageSize = request.PageSize, - TypeFilter = request.TypeFilter - }, ctx); + var resp = + await inner.AutocompleteAsync( + new AutocompleteRequest + { + Query = request.Query, + PageNumber = request.PageNumber, + PageSize = request.PageSize, + TypeFilter = request.TypeFilter + }, + ctx + ); var response = new NavigationSearchResponse { @@ -40,17 +44,24 @@ public async Task NavigationSearchAsync(NavigationSear PageNumber = resp.PageNumber, PageSize = resp.PageSize, Aggregations = new NavigationSearchAggregations { Type = resp.Aggregations.Type }, - Results = resp.Results.Select(item => new NavigationSearchResultItem - { - Type = item.Document.ContentType, - Url = item.Document.Path, - Title = item.Title, - Description = item.Description, - Parents = (item.Document.Parents ?? []) - .Select(p => new NavigationSearchResultItemParent { Title = p.Title, Url = p.Path }) - .ToArray(), - Score = item.Score - }).ToList() + Results = + resp.Results + .Select( + item => + new NavigationSearchResultItem + { + Type = item.Document.ContentType, + Url = item.Document.Path, + Title = item.Title, + Description = item.Description, + Parents = + (item.Document.Parents ?? []).Select( + p => new NavigationSearchResultItemParent { Title = p.Title, Url = p.Path } + ).ToArray(), + Score = item.Score + } + ) + .ToList() }; LogNavigationSearchResults( @@ -58,7 +69,8 @@ public async Task NavigationSearchAsync(NavigationSear response.PageSize, response.PageNumber, request.Query, - response.Results.Select(i => i.Url).ToArray()); + response.Results.Select(i => i.Url).ToArray() + ); return response; } @@ -86,12 +98,21 @@ public async Task ExplainDocumentAsync(string query, string docum } public async Task<(ExplainResult TopResult, ExplainResult ExpectedResult)> ExplainTopResultAndExpectedAsync( - string query, string expectedDocumentUrl, Cancel ctx = default) + string query, + string expectedDocumentUrl, + Cancel ctx = default + ) { if (inner is DefaultSearchService defaultImpl) return await defaultImpl.ExplainTopResultAndExpectedAsync(query, expectedDocumentUrl, ctx); - var noop = new ExplainResult { SearchTitle = "N/A", DocumentUrl = "N/A", Found = false, Explanation = "Explain unavailable on non-default ISearchService impl." }; + var noop = new ExplainResult + { + SearchTitle = "N/A", + DocumentUrl = "N/A", + Found = false, + Explanation = "Explain unavailable on non-default ISearchService impl." + }; return (noop, noop); } diff --git a/src/services/search/Elastic.Documentation.Search/Query/QueryFieldNames.cs b/src/services/search/Elastic.Documentation.Search/Query/QueryFieldNames.cs index 9512a4af68..31a2506a4e 100644 --- a/src/services/search/Elastic.Documentation.Search/Query/QueryFieldNames.cs +++ b/src/services/search/Elastic.Documentation.Search/Query/QueryFieldNames.cs @@ -10,7 +10,10 @@ namespace Elastic.Documentation.Search; /// Canonical Elasticsearch field names used by the shared query builder. public static class QueryFieldNames { - private static DocumentationMappingContext.DocumentationDocumentResolver Doc { get; } = DocumentationMappingContext.DocumentationDocument; + private static DocumentationMappingContext.DocumentationDocumentResolver Doc + { + get; + } = DocumentationMappingContext.DocumentationDocument; public static string ContentType { get; } = Doc.Fields.ContentType; public static string Section { get; } = Doc.Fields.Section; diff --git a/src/services/search/Elastic.Documentation.Search/Query/SearchQueryBuilder.cs b/src/services/search/Elastic.Documentation.Search/Query/SearchQueryBuilder.cs index e847602563..4fef43c538 100644 --- a/src/services/search/Elastic.Documentation.Search/Query/SearchQueryBuilder.cs +++ b/src/services/search/Elastic.Documentation.Search/Query/SearchQueryBuilder.cs @@ -26,17 +26,14 @@ public static class SearchQueryBuilder ]; /// Excludes hidden documents and bare root-URL placeholders. - public static Query DocumentFilter { get; } = - new BoolQuery - { - MustNot = - [ - new TermsQuery( - QueryFieldNames.PathKeyword, - new TermsQueryField(["/docs", "/docs/", "/docs/404", "/docs/404/"])), - new TermQuery { Field = QueryFieldNames.Hidden, Value = true } - ] - }; + public static Query DocumentFilter { get; } = new BoolQuery + { + MustNot = + [ + new TermsQuery(QueryFieldNames.PathKeyword, new TermsQueryField(["/docs", "/docs/", "/docs/404", "/docs/404/"])), + new TermQuery { Field = QueryFieldNames.Hidden, Value = true } + ] + }; public static Query? BuildDiminishQuery(IReadOnlyCollection diminishTerms) { @@ -64,9 +61,7 @@ public static Query WrapWithRuleQuery(Query query, string searchQuery, string? r }; } - public static Query? GenerateTitleKeywordQuery( - string searchQuery, - IReadOnlyDictionary synonymBiDirectional) + public static Query? GenerateTitleKeywordQuery(string searchQuery, IReadOnlyDictionary synonymBiDirectional) { var q = searchQuery.ToLowerInvariant(); @@ -90,19 +85,15 @@ public static Query WrapWithRuleQuery(Query query, string searchQuery, string? r public static Query BuildSemanticQuery(string searchQuery) => (Query)new SemanticQuery(QueryFieldNames.TitleSemanticText, searchQuery) { Boost = 5.0f } - || new SemanticQuery(QueryFieldNames.SummarySemanticText, searchQuery) { Boost = 3.0f } - || new SemanticQuery(QueryFieldNames.AiRagSummarySemanticText, searchQuery) { Boost = 4.0f } - || new SemanticQuery(QueryFieldNames.AiQuestionsSemanticText, searchQuery) { Boost = 2.0f }; + || new SemanticQuery(QueryFieldNames.SummarySemanticText, searchQuery) { Boost = 3.0f } + || new SemanticQuery(QueryFieldNames.AiRagSummarySemanticText, searchQuery) { Boost = 4.0f } + || new SemanticQuery(QueryFieldNames.AiQuestionsSemanticText, searchQuery) { Boost = 2.0f }; // NOTE: BuildSemanticQueryProbe / BuildLexicalQueryProbe use SearchQueryComponents, now available // from the in-repo contract — restore these in a follow-up. public static Query BuildUrlMatchQuery(string searchQuery) => - new ConstantScoreQuery - { - Filter = new MatchQuery { Field = QueryFieldNames.PathMatch, Query = searchQuery }, - Boost = 0.3f - }; + new ConstantScoreQuery { Filter = new MatchQuery { Field = QueryFieldNames.PathMatch, Query = searchQuery }, Boost = 0.3f }; public static Query? BuildTitleStartsWithQuery(string searchQuery) { @@ -118,12 +109,7 @@ public static Query BuildUrlMatchQuery(string searchQuery) => return new ConstantScoreQuery { - Filter = new TermQuery - { - Field = QueryFieldNames.TitleStartsWith, - Value = searchQuery.ToLowerInvariant(), - Boost = boost - }, + Filter = new TermQuery { Field = QueryFieldNames.TitleStartsWith, Value = searchQuery.ToLowerInvariant(), Boost = boost }, Boost = boost }; } @@ -149,37 +135,36 @@ public static Query BuildLexicalQuery( string searchQuery, IReadOnlyDictionary synonymBiDirectional, IReadOnlyCollection diminishTerms, - string? rulesetName) + string? rulesetName + ) { var tokens = searchQuery.Split(' ', StringSplitOptions.RemoveEmptyEntries); - var query = - (Query)new ConstantScoreQuery - { - Filter = new MultiMatchQuery - { - Query = searchQuery, - Operator = Operator.And, - Type = TextQueryType.BoolPrefix, - Analyzer = "synonyms_analyzer", - Fields = new[] - { - QueryFieldNames.SearchTitleCompletion, - QueryFieldNames.SearchTitleCompletion2Gram, - QueryFieldNames.SearchTitleCompletion3Gram - } - }, - Boost = 3.0f - } - || new MultiMatchQuery + var query = (Query)new ConstantScoreQuery + { + Filter = new MultiMatchQuery { Query = searchQuery, Operator = Operator.And, - Type = TextQueryType.BestFields, + Type = TextQueryType.BoolPrefix, Analyzer = "synonyms_analyzer", - Boost = 0.1f, - Fields = new[] { QueryFieldNames.Body } - }; + Fields = new[] + { + QueryFieldNames.SearchTitleCompletion, + QueryFieldNames.SearchTitleCompletion2Gram, + QueryFieldNames.SearchTitleCompletion3Gram + } + }, + Boost = 3.0f + } || new MultiMatchQuery + { + Query = searchQuery, + Operator = Operator.And, + Type = TextQueryType.BestFields, + Analyzer = "synonyms_analyzer", + Boost = 0.1f, + Fields = new[] { QueryFieldNames.Body } + }; var titleKeywordQuery = GenerateTitleKeywordQuery(searchQuery, synonymBiDirectional); if (titleKeywordQuery is not null) @@ -195,12 +180,7 @@ public static Query BuildLexicalQuery( if (tokens.Length > 2) query |= BuildPhraseMatchQuery(searchQuery); - var positiveQuery = new BoolQuery - { - Must = [query], - Filter = [DocumentFilter], - Should = ScoringQueries - }; + var positiveQuery = new BoolQuery { Must = [query], Filter = [DocumentFilter], Should = ScoringQueries }; var diminishQuery = BuildDiminishQuery(diminishTerms); var baseQuery = ApplyDiminishBoost(positiveQuery, diminishQuery); @@ -213,12 +193,7 @@ public static Query ApplyDiminishBoost(Query positiveQuery, Query? diminishQuery if (diminishQuery is null) return positiveQuery; - return new BoostingQuery - { - Positive = positiveQuery, - NegativeBoost = 0.8, - Negative = diminishQuery - }; + return new BoostingQuery { Positive = positiveQuery, NegativeBoost = 0.8, Negative = diminishQuery }; } } diff --git a/src/services/search/Elastic.Documentation.Search/SearchUnavailableException.cs b/src/services/search/Elastic.Documentation.Search/SearchUnavailableException.cs index c1bcf27b3f..909afdee14 100644 --- a/src/services/search/Elastic.Documentation.Search/SearchUnavailableException.cs +++ b/src/services/search/Elastic.Documentation.Search/SearchUnavailableException.cs @@ -14,5 +14,4 @@ namespace Elastic.Documentation.Search; /// error rather than a permanent failure. /// /// -public class SearchUnavailableException(string message, Exception? innerException = null) - : Exception(message, innerException); +public class SearchUnavailableException(string message, Exception? innerException = null) : Exception(message, innerException); diff --git a/src/services/search/Elastic.Documentation.Search/ServicesExtension.cs b/src/services/search/Elastic.Documentation.Search/ServicesExtension.cs index 383649eabb..91d2536f2a 100644 --- a/src/services/search/Elastic.Documentation.Search/ServicesExtension.cs +++ b/src/services/search/Elastic.Documentation.Search/ServicesExtension.cs @@ -37,23 +37,23 @@ public static IServiceCollection AddSearchServices(this IServiceCollection servi // Inner search service: docs-builder pairs the typed contract with the docs index alias. // SearchQueryConfiguration is a lean projection of the richer docs-builder SearchConfiguration, // carrying only the four values the query path reads. - _ = services.AddScoped>(sp => - { - var acc = sp.GetRequiredService(); - var lookup = sp.GetRequiredService(); - var innerLogger = sp.GetRequiredService>>(); - - var queryConfig = new SearchQueryConfiguration + _ = + services.AddScoped>(sp => { - SynonymBiDirectional = acc.SynonymBiDirectional, - DiminishTerms = acc.DiminishTerms, - RulesetName = acc.RulesetName, - SemanticEnabled = true - }; + var acc = sp.GetRequiredService(); + var lookup = sp.GetRequiredService(); + var innerLogger = sp.GetRequiredService>>(); + + var queryConfig = new SearchQueryConfiguration + { + SynonymBiDirectional = acc.SynonymBiDirectional, + DiminishTerms = acc.DiminishTerms, + RulesetName = acc.RulesetName, + SemanticEnabled = true + }; - return new DefaultSearchService( - acc.Client, acc.SearchIndex, queryConfig, innerLogger, lookup); - }); + return new DefaultSearchService(acc.Client, acc.SearchIndex, queryConfig, innerLogger, lookup); + }); // Docs-specific adapters preserve the existing API/MCP wire format. _ = services.AddScoped(); diff --git a/src/services/search/Elastic.Documentation.Search/SharedPointInTimeManager.cs b/src/services/search/Elastic.Documentation.Search/SharedPointInTimeManager.cs index 1b0f6b637f..25a341df2d 100644 --- a/src/services/search/Elastic.Documentation.Search/SharedPointInTimeManager.cs +++ b/src/services/search/Elastic.Documentation.Search/SharedPointInTimeManager.cs @@ -53,17 +53,11 @@ public async Task GetPitIdAsync(Cancel ctx, string? expiredPitId = null) private async Task OpenPit(Cancel ctx) { - var response = await clientAccessor.Client.OpenPointInTimeAsync( - clientAccessor.SearchIndex, - r => r.KeepAlive(PitKeepAlive), - ctx - ); + var response = await clientAccessor.Client.OpenPointInTimeAsync(clientAccessor.SearchIndex, r => r.KeepAlive(PitKeepAlive), ctx); if (!response.IsValidResponse) { - throw new InvalidOperationException( - $"Failed to open PIT: {response.ElasticsearchServerError?.Error?.Reason ?? "Unknown"}" - ); + throw new InvalidOperationException($"Failed to open PIT: {response.ElasticsearchServerError?.Error?.Reason ?? "Unknown"}"); } LogPitOpened(logger, response.Id); diff --git a/src/tooling/adoc-compare/Program.cs b/src/tooling/adoc-compare/Program.cs index a462b13e24..9b1f8d4b84 100644 --- a/src/tooling/adoc-compare/Program.cs +++ b/src/tooling/adoc-compare/Program.cs @@ -8,12 +8,7 @@ var opts = new AsciidocParserOptions { FileReader = path => File.Exists(path) ? File.ReadAllText(path) : null, - Attributes = new Dictionary - { - ["my-product"] = "Elasticsearch", - ["version"] = "8.16", - ["enterprise-only"] = "" - } + Attributes = new Dictionary { ["my-product"] = "Elasticsearch", ["version"] = "8.16", ["enterprise-only"] = "" } }; var parser = new AsciidocParser(opts); var doc = parser.Parse(inputFile); diff --git a/src/tooling/docs-builder/Arguments/BundleInputParser.cs b/src/tooling/docs-builder/Arguments/BundleInputParser.cs index 0bd05bb6da..c27d0209e9 100644 --- a/src/tooling/docs-builder/Arguments/BundleInputParser.cs +++ b/src/tooling/docs-builder/Arguments/BundleInputParser.cs @@ -35,7 +35,10 @@ public static class BundleInputParser { BundleFile = NormalizePath(parts[0]), Repo = parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1]) ? parts[1] : null, - HideLinks = parts.Length > 2 && !string.IsNullOrWhiteSpace(parts[2]) && parts[2].Equals("hide-links", StringComparison.OrdinalIgnoreCase) + HideLinks = + parts.Length > 2 + && !string.IsNullOrWhiteSpace(parts[2]) + && parts[2].Equals("hide-links", StringComparison.OrdinalIgnoreCase) }; } @@ -81,9 +84,7 @@ private static string NormalizePath(string path) var afterTilde = trimmedPath[2..]; var relativeFromHome = GetRelativePathSegment(afterTilde); // Ensure that combining with homeDirectory cannot drop the base path if the segment is rooted - trimmedPath = Path.IsPathRooted(relativeFromHome) - ? homeDirectory - : Path.Join(homeDirectory, relativeFromHome); + trimmedPath = Path.IsPathRooted(relativeFromHome) ? homeDirectory : Path.Join(homeDirectory, relativeFromHome); } else if (trimmedPath == "~") { @@ -116,4 +117,3 @@ private static string GetRelativePathSegment(string pathSegment) return segment.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); } } - diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerAiEnrichCommand.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerAiEnrichCommand.cs index 09094129f4..640cb95bb7 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerAiEnrichCommand.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerAiEnrichCommand.cs @@ -46,9 +46,7 @@ public async Task AiEnrich( await using var serviceInvoker = new ServiceInvoker(collector); var fs = CheckoutsFileSystem.FromWorkingDirectory(); var service = new AssemblerAiEnrichService(logFactory, configuration, configurationContext, githubActionsService); - serviceInvoker.AddCommand(service, - async (s, col, ctx) => await s.AiEnrich(col, fs, es, environment, bootstrapOnly, ctx) - ); + serviceInvoker.AddCommand(service, async (s, col, ctx) => await s.AiEnrich(col, fs, es, environment, bootstrapOnly, ctx)); return await serviceInvoker.InvokeAsync(ct); } } diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs index 63927b844c..f65e1d9c13 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs @@ -59,13 +59,25 @@ public async Task Assemble( AssumeCloned = assumeCloned }; var cloneService = new AssemblerCloneService(logFactory, assemblyConfiguration, configurationContext, githubActionsService); - serviceInvoker.AddCommand(cloneService, cloneOptions, buildOptions.Strict ?? false, + serviceInvoker.AddCommand( + cloneService, + cloneOptions, + buildOptions.Strict ?? false, static async (s, col, opts, ctx) => await s.CloneAll(col, opts, ctx) ); var fs = CheckoutsFileSystem.FromWorkingDirectory(); - var buildService = new AssemblerBuildService(logFactory, assemblyConfiguration, configurationContext, githubActionsService, environmentVariables); - serviceInvoker.AddCommand(buildService, (buildOptions, fs), buildOptions.Strict ?? false, + var buildService = new AssemblerBuildService( + logFactory, + assemblyConfiguration, + configurationContext, + githubActionsService, + environmentVariables + ); + serviceInvoker.AddCommand( + buildService, + (buildOptions, fs), + buildOptions.Strict ?? false, static async (s, col, state, ctx) => await s.BuildAll(col, state.buildOptions, state.fs, ctx) ); var result = await serviceInvoker.InvokeAsync(ct); @@ -123,13 +135,13 @@ public async Task Clone( await using var serviceInvoker = new ServiceInvoker(collector); var options = new AssemblerCloneOptions { - Strict = strict, Environment = environment, - FetchLatest = fetchLatest, AssumeCloned = assumeCloned + Strict = strict, + Environment = environment, + FetchLatest = fetchLatest, + AssumeCloned = assumeCloned }; var service = new AssemblerCloneService(logFactory, assemblyConfiguration, configurationContext, githubActionsService); - serviceInvoker.AddCommand(service, options, strict ?? false, - static async (s, col, opts, ctx) => await s.CloneAll(col, opts, ctx) - ); + serviceInvoker.AddCommand(service, options, strict ?? false, static async (s, col, opts, ctx) => await s.CloneAll(col, opts, ctx)); return await serviceInvoker.InvokeAsync(ct); } @@ -140,15 +152,21 @@ static async (s, col, opts, ctx) => await s.CloneAll(col, opts, ctx) /// [CommandIntent(Intent.Idempotent)] [NoOptionsInjection] - public async Task Build( - [AsParameters] AssemblerBuildOptions options, - CancellationToken ct = default - ) + public async Task Build([AsParameters] AssemblerBuildOptions options, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); var fs = CheckoutsFileSystem.FromWorkingDirectory(); - var service = new AssemblerBuildService(logFactory, assemblyConfiguration, configurationContext, githubActionsService, environmentVariables); - serviceInvoker.AddCommand(service, (options, fs), options.Strict ?? false, + var service = new AssemblerBuildService( + logFactory, + assemblyConfiguration, + configurationContext, + githubActionsService, + environmentVariables + ); + serviceInvoker.AddCommand( + service, + (options, fs), + options.Strict ?? false, static async (s, col, state, ctx) => await s.BuildAll(col, state.options, state.fs, ctx) ); return await serviceInvoker.InvokeAsync(ct); @@ -160,7 +178,11 @@ static async (s, col, state, ctx) => await s.BuildAll(col, state.options, state. /// Path to the built site. Defaults to .artifacts/docs/. [NoOptionsInjection] - public async Task Serve(int port = 4000, [Existing, ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? path = null, CancellationToken ct = default) + public async Task Serve( + int port = 4000, + [Existing, ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? path = null, + CancellationToken ct = default + ) { var host = new StaticWebHost(port, path?.FullName); await host.RunAsync(ct); diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs index c384c647be..5281468e1c 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs @@ -44,10 +44,14 @@ public async Task Index( { await using var serviceInvoker = new ServiceInvoker(collector); var fs = CheckoutsFileSystem.FromWorkingDirectory(); - var service = new AssemblerIndexService(logFactory, configuration, configurationContext, githubActionsService, environmentVariables); - serviceInvoker.AddCommand(service, - async (s, col, ctx) => await s.Index(col, fs, es, environment, ctx) + var service = new AssemblerIndexService( + logFactory, + configuration, + configurationContext, + githubActionsService, + environmentVariables ); + serviceInvoker.AddCommand(service, async (s, col, ctx) => await s.Index(col, fs, es, environment, ctx)); return await serviceInvoker.InvokeAsync(ct); } } diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs index 0f0407d18f..7b16a1b7c8 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs @@ -44,9 +44,7 @@ public async Task Sitemap( await using var serviceInvoker = new ServiceInvoker(collector); var fs = CheckoutsFileSystem.FromWorkingDirectory(); var service = new AssemblerSitemapService(logFactory, configuration, configurationContext, githubActionsService); - serviceInvoker.AddCommand(service, - async (s, col, ctx) => await s.GenerateSitemapAsync(col, fs, es, environment, ctx) - ); + serviceInvoker.AddCommand(service, async (s, col, ctx) => await s.GenerateSitemapAsync(col, fs, es, environment, ctx)); return await serviceInvoker.InvokeAsync(ct); } } diff --git a/src/tooling/docs-builder/Commands/Assembler/BloomFilterCommands.cs b/src/tooling/docs-builder/Commands/Assembler/BloomFilterCommands.cs index 5170889fc0..ef430f1a24 100644 --- a/src/tooling/docs-builder/Commands/Assembler/BloomFilterCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/BloomFilterCommands.cs @@ -22,18 +22,25 @@ internal sealed class BloomFilterCommands(ILoggerFactory logFactory, IDiagnostic /// /// Path to the local legacy-docs repository checkout. [NoOptionsInjection] - public async Task Create([Existing, ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo builtDocsDir, CancellationToken ct = default) + public async Task Create( + [Existing, ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo builtDocsDir, + CancellationToken ct = default + ) { await using var serviceInvoker = new ServiceInvoker(collector); var pagesProvider = new LocalPagesProvider(builtDocsDir.FullName); var legacyPageService = new LegacyPageService(logFactory); - serviceInvoker.AddCommand(legacyPageService, pagesProvider, static (s, _, pagesProvider, _) => - { - var result = s.GenerateBloomFilterBinary(pagesProvider); - return Task.FromResult(result); - }); + serviceInvoker.AddCommand( + legacyPageService, + pagesProvider, + static (s, _, pagesProvider, _) => + { + var result = s.GenerateBloomFilterBinary(pagesProvider); + return Task.FromResult(result); + } + ); return await serviceInvoker.InvokeAsync(ct); } @@ -45,11 +52,15 @@ public async Task Lookup(string path, CancellationToken ct = default) await using var serviceInvoker = new ServiceInvoker(collector); var legacyPageService = new LegacyPageService(logFactory); - serviceInvoker.AddCommand(legacyPageService, path, static (s, _, path, _) => - { - var result = s.PathExists(path, logResult: true); - return Task.FromResult(result); - }); + serviceInvoker.AddCommand( + legacyPageService, + path, + static (s, _, path, _) => + { + var result = s.PathExists(path, logResult: true); + return Task.FromResult(result); + } + ); return await serviceInvoker.InvokeAsync(ct); } } diff --git a/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs b/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs index 88491515c4..0d90666ed1 100644 --- a/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs @@ -35,8 +35,11 @@ public async Task Init(string? gitRef = null, bool local = false, Cancellat await using var serviceInvoker = new ServiceInvoker(collector); var service = new ConfigurationCloneService(logFactory, assemblyConfiguration, CheckoutsFileSystem.FromWorkingDirectory()); - serviceInvoker.AddCommand(service, (gitRef, local), static async (s, collector, state, ctx) => - await s.InitConfigurationToApplicationData(collector, state.gitRef, state.local, ctx)); + serviceInvoker.AddCommand( + service, + (gitRef, local), + static async (s, collector, state, ctx) => await s.InitConfigurationToApplicationData(collector, state.gitRef, state.local, ctx) + ); return await serviceInvoker.InvokeAsync(ct); } } diff --git a/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs b/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs index c19dbdd7d2..1c453ed05d 100644 --- a/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs @@ -43,20 +43,27 @@ public async Task Validate(CancellationToken ct = default) /// Repository slug to match (e.g. elastic/elasticsearch). /// Branch name or version tag to test against. [NoOptionsInjection] - public async Task Match([Argument] string? repository = null, [Argument] string? branchOrTag = null, CancellationToken ct = default) + public async Task Match( + [Argument] string? repository = null, + [Argument] string? branchOrTag = null, + CancellationToken ct = default + ) { await using var serviceInvoker = new ServiceInvoker(collector); var fs = CheckoutsFileSystem.FromWorkingDirectory(); var service = new RepositoryBuildMatchingService(logFactory, configuration, configurationContext, githubActionsService, fs); - serviceInvoker.AddCommand(service, (repository, branchOrTag), + serviceInvoker.AddCommand( + service, + (repository, branchOrTag), static async (s, collector, state, ctx) => { // ShouldBuild emits GitHub Actions outputs to drive conditional CI steps; // exit code is always 0 — the bool result is communicated via those outputs, not the process exit. _ = await s.ShouldBuild(collector, state.repository, state.branchOrTag, ctx); return true; - }); + } + ); return await serviceInvoker.InvokeAsync(ct); } diff --git a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs index 30d3f661a0..b19cfe1994 100644 --- a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs @@ -40,15 +40,24 @@ ICoreService githubActionsService [RequiresAuth] [MutationScope(MutationScope.Global)] [NoOptionsInjection] - public async Task Plan(string environment, string s3BucketName, [ExpandUserProfile, RejectSymbolicLinks] FileInfo? @out = null, float? deleteThreshold = null, CancellationToken ct = default) + public async Task Plan( + string environment, + string s3BucketName, + [ExpandUserProfile, RejectSymbolicLinks] FileInfo? @out = null, + float? deleteThreshold = null, + CancellationToken ct = default + ) { await using var serviceInvoker = new ServiceInvoker(collector); var fs = CheckoutsFileSystem.FromWorkingDirectory(); var context = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, fs); var service = new IncrementalDeployService(logFactory, githubActionsService); - serviceInvoker.AddCommand(service, (context, s3BucketName, @out, deleteThreshold), - static async (s, collector, state, ctx) => await s.Plan(collector, state.context, state.s3BucketName, state.@out?.FullName ?? "", state.deleteThreshold, [], ctx) + serviceInvoker.AddCommand( + service, + (context, s3BucketName, @out, deleteThreshold), + static async (s, collector, state, ctx) => + await s.Plan(collector, state.context, state.s3BucketName, state.@out?.FullName ?? "", state.deleteThreshold, [], ctx) ); return await serviceInvoker.InvokeAsync(ct); } @@ -62,15 +71,23 @@ static async (s, collector, state, ctx) => await s.Plan(collector, state.context [CommandIntent(Intent.Destructive)] [MutationScope(MutationScope.Global)] [NoOptionsInjection] - public async Task Apply(string environment, string s3BucketName, [Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "json,plan")] FileInfo planFile, CancellationToken ct = default) + public async Task Apply( + string environment, + string s3BucketName, + [Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "json,plan")] FileInfo planFile, + CancellationToken ct = default + ) { await using var serviceInvoker = new ServiceInvoker(collector); var fs = CheckoutsFileSystem.FromWorkingDirectory(); var context = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, fs); var service = new IncrementalDeployService(logFactory, githubActionsService); - serviceInvoker.AddCommand(service, (context, s3BucketName, planFile), - static async (s, collector, state, ctx) => await s.Apply(collector, state.context, state.s3BucketName, state.planFile.FullName, ctx) + serviceInvoker.AddCommand( + service, + (context, s3BucketName, planFile), + static async (s, collector, state, ctx) => + await s.Apply(collector, state.context, state.s3BucketName, state.planFile.FullName, ctx) ); return await serviceInvoker.InvokeAsync(ct); } @@ -80,13 +97,20 @@ static async (s, collector, state, ctx) => await s.Apply(collector, state.contex /// Named deployment target. /// Path to redirects.json. Defaults to .artifacts/docs/redirects.json. [NoOptionsInjection] - public async Task UpdateRedirects(string environment, [Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "json")] FileInfo? redirectsFile = null, CancellationToken ct = default) + public async Task UpdateRedirects( + string environment, + [Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "json")] FileInfo? redirectsFile = null, + CancellationToken ct = default + ) { await using var serviceInvoker = new ServiceInvoker(collector); var service = new DeployUpdateRedirectsService(logFactory, CheckoutsFileSystem.FromWorkingDirectory()); - serviceInvoker.AddCommand(service, (environment, redirectsFile), - static async (s, collector, state, ctx) => await s.UpdateRedirects(collector, state.environment, state.redirectsFile?.FullName, ctx: ctx) + serviceInvoker.AddCommand( + service, + (environment, redirectsFile), + static async (s, collector, state, ctx) => + await s.UpdateRedirects(collector, state.environment, state.redirectsFile?.FullName, ctx: ctx) ); return await serviceInvoker.InvokeAsync(ct); } diff --git a/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs b/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs index 9a3e877e26..e75b97fbf3 100644 --- a/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs @@ -29,7 +29,12 @@ IConfigurationContext configurationContext public async Task Validate(CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var service = new GlobalNavigationService(logFactory, configuration, configurationContext, CheckoutsFileSystem.FromWorkingDirectory()); + var service = new GlobalNavigationService( + logFactory, + configuration, + configurationContext, + CheckoutsFileSystem.FromWorkingDirectory() + ); serviceInvoker.AddCommand(service, static async (s, collector, ctx) => await s.Validate(collector, ctx)); return await serviceInvoker.InvokeAsync(ct); } @@ -37,11 +42,23 @@ public async Task Validate(CancellationToken ct = default) /// Check that no link in a local links.json conflicts with a path prefix defined in navigation.yml. /// Path to links.json. Defaults to .artifacts/docs/html/links.json. [NoOptionsInjection] - public async Task ValidateLinkReference([Argument, Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "json")] FileInfo? file = null, CancellationToken ct = default) + public async Task ValidateLinkReference( + [Argument, Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "json")] FileInfo? file = null, + CancellationToken ct = default + ) { await using var serviceInvoker = new ServiceInvoker(collector); - var service = new GlobalNavigationService(logFactory, configuration, configurationContext, CheckoutsFileSystem.FromWorkingDirectory()); - serviceInvoker.AddCommand(service, file, static async (s, collector, file, ctx) => await s.ValidateLocalLinkReference(collector, file?.FullName, ctx)); + var service = new GlobalNavigationService( + logFactory, + configuration, + configurationContext, + CheckoutsFileSystem.FromWorkingDirectory() + ); + serviceInvoker.AddCommand( + service, + file, + static async (s, collector, file, ctx) => await s.ValidateLocalLinkReference(collector, file?.FullName, ctx) + ); return await serviceInvoker.InvokeAsync(ct); } } diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 1d4cd6df3d..9d24d0a6aa 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -334,8 +334,12 @@ public async Task Add( // Load changelog config and apply fallbacks for all modes. // Precedence: CLI option > bundle section in changelog.yml > built-in default. // This applies to --prs, --issues, --release-version, and --report alike. - var bundleConfig = await new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem) - .LoadChangelogConfiguration(collector, config?.FullName, ctx); + var bundleConfig = + await new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem).LoadChangelogConfiguration( + collector, + config?.FullName, + ctx + ); var resolvedRepo = !string.IsNullOrWhiteSpace(repo) ? repo : bundleConfig?.Bundle?.Repo; var resolvedOwner = owner ?? bundleConfig?.Bundle?.Owner ?? "elastic"; var resolvedOutput = !string.IsNullOrWhiteSpace(output) ? output : bundleConfig?.Bundle?.Directory; @@ -348,7 +352,10 @@ public async Task Add( { if (string.IsNullOrWhiteSpace(resolvedRepo)) { - collector.EmitError(string.Empty, "--release-version requires --repo to be specified (or bundle.repo set in changelog.yml)."); + collector.EmitError( + string.Empty, + "--release-version requires --repo to be specified (or bundle.repo set in changelog.yml)." + ); _ = collector.StartAsync(ctx); await collector.WaitForDrain(); await collector.StopAsync(ctx); @@ -358,7 +365,13 @@ public async Task Add( var repoArg = resolvedRepo.Contains('/') ? resolvedRepo : $"{resolvedOwner}/{resolvedRepo}"; IGitHubReleaseService releaseService = new GitHubReleaseService(logFactory); IGitHubPrService prService = new GitHubPrService(logFactory); - var releaseChangelogService = new GitHubReleaseChangelogService(logFactory, configurationContext, _fileSystem, releaseService, prService); + var releaseChangelogService = new GitHubReleaseChangelogService( + logFactory, + configurationContext, + _fileSystem, + releaseService, + prService + ); var releaseInput = new CreateChangelogsFromReleaseArguments { @@ -370,23 +383,33 @@ public async Task Add( CreateBundle = false }; - serviceInvoker.AddCommand(releaseChangelogService, releaseInput, - async static (s, collector, state, ctx) => await s.CreateChangelogsFromRelease(collector, state, ctx) + serviceInvoker.AddCommand( + releaseChangelogService, + releaseInput, + static async (s, collector, state, ctx) => await s.CreateChangelogsFromRelease(collector, state, ctx) ); return await serviceInvoker.InvokeAsync(ctx); } IGitHubPrService githubPrService = new GitHubPrService(logFactory); - var service = new ChangelogCreationService(logFactory, configurationContext, _fileSystem, githubPrService, env: SystemEnvironmentVariables.Instance); + var service = new ChangelogCreationService( + logFactory, + configurationContext, + _fileSystem, + githubPrService, + env: SystemEnvironmentVariables.Instance + ); // Parse PRs: promotion report (--report), or comma-separated values and file paths (--prs) string[]? parsedPrs = null; if (hasReport) { var reportSource = report!.Trim(); - if (!reportSource.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && - !reportSource.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + if ( + !reportSource.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && + !reportSource.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + ) reportSource = NormalizePath(reportSource); var reportParser = new PromotionReportParser(logFactory, _fileSystem); @@ -537,8 +560,10 @@ async static (s, collector, state, ctx) => await s.CreateChangelogsFromRelease(c StrictFetch = strictFetch }; - serviceInvoker.AddCommand(service, input, - async static (s, collector, state, ctx) => await s.CreateChangelog(collector, state, ctx) + serviceInvoker.AddCommand( + service, + input, + static async (s, collector, state, ctx) => await s.CreateChangelog(collector, state, ctx) ); return await serviceInvoker.InvokeAsync(ctx); @@ -623,14 +648,19 @@ public async Task Bundle( if (isGitRefMode && (string.IsNullOrWhiteSpace(startGitRef) || string.IsNullOrWhiteSpace(endGitRef))) { - collector.EmitError(string.Empty, - "--start-git-ref and --end-git-ref must be provided together; the start ref is never inferred from previous bundles."); + collector.EmitError( + string.Empty, + "--start-git-ref and --end-git-ref must be provided together; the start ref is never inferred from previous bundles." + ); return 1; } if (dryRun && !isGitRefMode) { - collector.EmitError(string.Empty, "--dry-run is only supported when bundling a git commit range (--start-git-ref/--end-git-ref)."); + collector.EmitError( + string.Empty, + "--dry-run is only supported when bundling a git commit range (--start-git-ref/--end-git-ref)." + ); return 1; } @@ -643,24 +673,39 @@ public async Task Bundle( // --release-version mode: resolve the release into a PR list and proceed as if --prs was specified if (releaseVersion != null) { - if (all || (inputProducts is { Count: > 0 }) || (prs is { Length: > 0 }) || (issues is { Length: > 0 }) || (files is { Length: > 0 })) + if ( + all + || (inputProducts is { Count: > 0 }) + || (prs is { Length: > 0 }) + || (issues is { Length: > 0 }) + || (files is { Length: > 0 }) + ) { - collector.EmitError(string.Empty, - "--release-version is mutually exclusive with --all, --input-products, --prs, --issues, and --files."); + collector.EmitError( + string.Empty, + "--release-version is mutually exclusive with --all, --input-products, --prs, --issues, and --files." + ); return 1; } if (!plan) { // Precedence: --repo CLI > bundle.repo config; --owner CLI > bundle.owner config > "elastic" - var bundleConfig = await new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem) - .LoadChangelogConfiguration(collector, config?.FullName, ctx); + var bundleConfig = + await new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem).LoadChangelogConfiguration( + collector, + config?.FullName, + ctx + ); var resolvedRepo = !string.IsNullOrWhiteSpace(repo) ? repo : bundleConfig?.Bundle?.Repo; var resolvedOwner = owner ?? bundleConfig?.Bundle?.Owner ?? "elastic"; if (string.IsNullOrWhiteSpace(resolvedRepo)) { - collector.EmitError(string.Empty, "--release-version requires --repo to be specified (or bundle.repo set in changelog.yml)."); + collector.EmitError( + string.Empty, + "--release-version requires --repo to be specified (or bundle.repo set in changelog.yml)." + ); return 1; } @@ -668,23 +713,26 @@ public async Task Bundle( var release = await releaseService.FetchReleaseAsync(resolvedOwner, resolvedRepo, releaseVersion, ctx); if (release == null) { - collector.EmitError(string.Empty, - $"Failed to fetch release '{releaseVersion}' for {resolvedOwner}/{resolvedRepo}. Ensure the tag exists and credentials are set."); + collector.EmitError( + string.Empty, + $"Failed to fetch release '{releaseVersion}' for {resolvedOwner}/{resolvedRepo}. Ensure the tag exists and credentials are set." + ); return 1; } var parsedNotes = ReleaseNoteParser.Parse(release.Body); if (parsedNotes.PrReferences.Count == 0) { - collector.EmitWarning(string.Empty, - $"No PR references found in release notes for {resolvedOwner}/{resolvedRepo}@{release.TagName}. No bundle will be created."); + collector.EmitWarning( + string.Empty, + $"No PR references found in release notes for {resolvedOwner}/{resolvedRepo}@{release.TagName}. No bundle will be created." + ); return 0; } // Build full PR URLs and inject them as the PR filter - prs = parsedNotes.PrReferences - .Select(r => $"https://github.com/{resolvedOwner}/{resolvedRepo}/pull/{r.PrNumber}") - .ToArray(); + prs = + parsedNotes.PrReferences.Select(r => $"https://github.com/{resolvedOwner}/{resolvedRepo}/pull/{r.PrNumber}").ToArray(); } } @@ -730,7 +778,7 @@ public async Task Bundle( collector.EmitError( string.Empty, $"When using a profile, the following options are not allowed: {string.Join(", ", forbidden)}. " + - "All paths and filters are derived from the changelog configuration file." + "All paths and filters are derived from the changelog configuration file." ); _ = collector.StartAsync(ctx); await collector.WaitForDrain(); @@ -772,7 +820,10 @@ public async Task Bundle( if (specifiedFilters.Count == 0) { - collector.EmitError(string.Empty, "At least one filter option must be specified: --all, --input-products, --prs, --issues, --report, --files, --start-git-ref/--end-git-ref, or use a profile (e.g., 'bundle elasticsearch-release 9.2.0')"); + collector.EmitError( + string.Empty, + "At least one filter option must be specified: --all, --input-products, --prs, --issues, --report, --files, --start-git-ref/--end-git-ref, or use a profile (e.g., 'bundle elasticsearch-release 9.2.0')" + ); _ = collector.StartAsync(ctx); await collector.WaitForDrain(); await collector.StopAsync(ctx); @@ -781,7 +832,10 @@ public async Task Bundle( if (specifiedFilters.Count > 1) { - collector.EmitError(string.Empty, $"Multiple filter options cannot be specified together. You specified: {string.Join(", ", specifiedFilters)}. Please use only one filter option: --all, --input-products, --prs, --issues, --report, --files, or --start-git-ref/--end-git-ref"); + collector.EmitError( + string.Empty, + $"Multiple filter options cannot be specified together. You specified: {string.Join(", ", specifiedFilters)}. Please use only one filter option: --all, --input-products, --prs, --issues, --report, --files, or --start-git-ref/--end-git-ref" + ); _ = collector.StartAsync(ctx); await collector.WaitForDrain(); await collector.StopAsync(ctx); @@ -808,7 +862,10 @@ public async Task Bundle( // If they're null, it means they weren't provided in the input if (product.Target == null) { - collector.EmitError(string.Empty, $"--input-products: target is required for product '{product.Product}' (use '*' for wildcard)"); + collector.EmitError( + string.Empty, + $"--input-products: target is required for product '{product.Product}' (use '*' for wildcard)" + ); _ = collector.StartAsync(ctx); await collector.WaitForDrain(); await collector.StopAsync(ctx); @@ -817,7 +874,10 @@ public async Task Bundle( if (product.Lifecycle == null) { - collector.EmitError(string.Empty, $"--input-products: lifecycle is required for product '{product.Product}' (use '*' for wildcard)"); + collector.EmitError( + string.Empty, + $"--input-products: lifecycle is required for product '{product.Product}' (use '*' for wildcard)" + ); _ = collector.StartAsync(ctx); await collector.WaitForDrain(); await collector.StopAsync(ctx); @@ -826,10 +886,10 @@ public async Task Bundle( } // Check if --input-products * * * is specified (equivalent to --all) - var isAllWildcard = inputProducts.Count == 1 && - inputProducts[0].Product == "*" && - inputProducts[0].Target == "*" && - inputProducts[0].Lifecycle == "*"; + var isAllWildcard = inputProducts.Count == 1 + && inputProducts[0].Product == "*" + && inputProducts[0].Target == "*" + && inputProducts[0].Lifecycle == "*"; if (isAllWildcard) { @@ -858,7 +918,10 @@ public async Task Bundle( if (!string.IsNullOrEmpty(extension)) { // Has an extension that's not .yml/.yaml - this is invalid - collector.EmitError(string.Empty, $"--output: If a filename is provided, it must end in .yml or .yaml. Found: {extension}"); + collector.EmitError( + string.Empty, + $"--output: If a filename is provided, it must end in .yml or .yaml. Found: {extension}" + ); _ = collector.StartAsync(ctx); await collector.WaitForDrain(); await collector.StopAsync(ctx); @@ -917,9 +980,11 @@ public async Task Bundle( if (!string.IsNullOrWhiteSpace(releaseDate)) forbidden.Add("--release-date"); - collector.EmitError(string.Empty, + collector.EmitError( + string.Empty, $"Profile mode does not support {string.Join(" and ", forbidden)}. " + - "Use bundle.release_dates or bundle.profiles..release_dates in changelog.yml instead."); + "Use bundle.release_dates or bundle.profiles..release_dates in changelog.yml instead." + ); return 1; } @@ -960,8 +1025,10 @@ public async Task Bundle( DryRun = dryRun }; - serviceInvoker.AddCommand(service, input, - async static (s, collector, state, ctx) => await s.BundleChangelogs(collector, state, ctx) + serviceInvoker.AddCommand( + service, + input, + static async (s, collector, state, ctx) => await s.BundleChangelogs(collector, state, ctx) ); return await serviceInvoker.InvokeAsync(ctx); @@ -1016,22 +1083,33 @@ public async Task Remove( // --release-version mode: resolve the release into a PR list and proceed as if --prs was specified if (releaseVersion != null) { - if (all || (products is { Count: > 0 }) || (prs is { Length: > 0 }) || (issues is { Length: > 0 }) || (files is { Length: > 0 })) + if ( + all || (products is { Count: > 0 }) || (prs is { Length: > 0 }) || (issues is { Length: > 0 }) || (files is { Length: > 0 }) + ) { - collector.EmitError(string.Empty, - "--release-version is mutually exclusive with --all, --products, --prs, --issues, and --files."); + collector.EmitError( + string.Empty, + "--release-version is mutually exclusive with --all, --products, --prs, --issues, and --files." + ); return 1; } // Precedence: --repo CLI > bundle.repo config; --owner CLI > bundle.owner config > "elastic" - var bundleConfig = await new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem) - .LoadChangelogConfiguration(collector, config?.FullName, ctx); + var bundleConfig = + await new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem).LoadChangelogConfiguration( + collector, + config?.FullName, + ctx + ); var resolvedRepo = !string.IsNullOrWhiteSpace(repo) ? repo : bundleConfig?.Bundle?.Repo; var resolvedOwner = owner ?? bundleConfig?.Bundle?.Owner ?? "elastic"; if (string.IsNullOrWhiteSpace(resolvedRepo)) { - collector.EmitError(string.Empty, "--release-version requires --repo to be specified (or bundle.repo set in changelog.yml)."); + collector.EmitError( + string.Empty, + "--release-version requires --repo to be specified (or bundle.repo set in changelog.yml)." + ); return 1; } @@ -1039,23 +1117,25 @@ public async Task Remove( var release = await releaseService.FetchReleaseAsync(resolvedOwner, resolvedRepo, releaseVersion, ctx); if (release == null) { - collector.EmitError(string.Empty, - $"Failed to fetch release '{releaseVersion}' for {resolvedOwner}/{resolvedRepo}. Ensure the tag exists and credentials are set."); + collector.EmitError( + string.Empty, + $"Failed to fetch release '{releaseVersion}' for {resolvedOwner}/{resolvedRepo}. Ensure the tag exists and credentials are set." + ); return 1; } var parsedNotes = ReleaseNoteParser.Parse(release.Body); if (parsedNotes.PrReferences.Count == 0) { - collector.EmitWarning(string.Empty, - $"No PR references found in release notes for {resolvedOwner}/{resolvedRepo}@{release.TagName}. No changelogs will be removed."); + collector.EmitWarning( + string.Empty, + $"No PR references found in release notes for {resolvedOwner}/{resolvedRepo}@{release.TagName}. No changelogs will be removed." + ); return 0; } // Build full PR URLs and inject them as the PR filter - prs = parsedNotes.PrReferences - .Select(r => $"https://github.com/{resolvedOwner}/{resolvedRepo}/pull/{r.PrNumber}") - .ToArray(); + prs = parsedNotes.PrReferences.Select(r => $"https://github.com/{resolvedOwner}/{resolvedRepo}/pull/{r.PrNumber}").ToArray(); } var allPrs = ExpandCommaSeparated(prs); @@ -1088,7 +1168,8 @@ public async Task Remove( collector.EmitError( string.Empty, $"When using a profile, the following options are not allowed: {string.Join(", ", forbidden)}. " + - "All paths and filters are derived from the changelog configuration file."); + "All paths and filters are derived from the changelog configuration file." + ); _ = collector.StartAsync(ctx); await collector.WaitForDrain(); await collector.StopAsync(ctx); @@ -1098,7 +1179,10 @@ public async Task Remove( // profileArg is required when profile is specified if (string.IsNullOrWhiteSpace(profileArg)) { - collector.EmitError(string.Empty, $"Profile '{profile}' requires a version number or promotion report URL as the second argument"); + collector.EmitError( + string.Empty, + $"Profile '{profile}' requires a version number or promotion report URL as the second argument" + ); _ = collector.StartAsync(ctx); await collector.WaitForDrain(); await collector.StopAsync(ctx); @@ -1136,7 +1220,10 @@ public async Task Remove( if (product.Target == null) { - collector.EmitError(string.Empty, $"--products: target is required for product '{product.Product}' (use '*' for wildcard)"); + collector.EmitError( + string.Empty, + $"--products: target is required for product '{product.Product}' (use '*' for wildcard)" + ); _ = collector.StartAsync(ctx); await collector.WaitForDrain(); await collector.StopAsync(ctx); @@ -1145,7 +1232,10 @@ public async Task Remove( if (product.Lifecycle == null) { - collector.EmitError(string.Empty, $"--products: lifecycle is required for product '{product.Product}' (use '*' for wildcard)"); + collector.EmitError( + string.Empty, + $"--products: lifecycle is required for product '{product.Product}' (use '*' for wildcard)" + ); _ = collector.StartAsync(ctx); await collector.WaitForDrain(); await collector.StopAsync(ctx); @@ -1154,10 +1244,10 @@ public async Task Remove( } // --products * * * is equivalent to --all - var isAllWildcard = products.Count == 1 && - products[0].Product == "*" && - products[0].Target == "*" && - products[0].Lifecycle == "*"; + var isAllWildcard = products.Count == 1 + && products[0].Product == "*" + && products[0].Target == "*" + && products[0].Lifecycle == "*"; if (isAllWildcard) { @@ -1190,8 +1280,10 @@ public async Task Remove( Report = !isProfileMode ? report : null }; - serviceInvoker.AddCommand(service, input, - async static (s, collector, state, ctx) => await s.RemoveChangelogs(collector, state, ctx) + serviceInvoker.AddCommand( + service, + input, + static async (s, collector, state, ctx) => await s.RemoveChangelogs(collector, state, ctx) ); return await serviceInvoker.InvokeAsync(ctx); @@ -1258,8 +1350,10 @@ public async Task Render( Config = config?.FullName }; - serviceInvoker.AddCommand(service, renderInput, - async static (s, collector, state, ctx) => await s.RenderChangelogs(collector, state, ctx) + serviceInvoker.AddCommand( + service, + renderInput, + static async (s, collector, state, ctx) => await s.RenderChangelogs(collector, state, ctx) ); return await serviceInvoker.InvokeAsync(ctx); @@ -1292,8 +1386,12 @@ public async Task GhRelease( await using var serviceInvoker = new ServiceInvoker(collector); // --output CLI > bundle.directory config > ./changelogs (service default) - var bundleConfig = await new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem) - .LoadChangelogConfiguration(collector, config?.FullName, ctx); + var bundleConfig = + await new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem).LoadChangelogConfiguration( + collector, + config?.FullName, + ctx + ); var resolvedOutput = !string.IsNullOrWhiteSpace(output) ? output : bundleConfig?.Bundle?.Directory; IGitHubReleaseService releaseService = new GitHubReleaseService(logFactory); @@ -1322,8 +1420,10 @@ public async Task GhRelease( ReleaseDate = releaseDate }; - serviceInvoker.AddCommand(service, input, - async static (s, collector, state, ctx) => await s.CreateChangelogsFromRelease(collector, state, ctx) + serviceInvoker.AddCommand( + service, + input, + static async (s, collector, state, ctx) => await s.CreateChangelogsFromRelease(collector, state, ctx) ); return await serviceInvoker.InvokeAsync(ctx); @@ -1351,12 +1451,8 @@ public async Task BundleAmend( var service = new ChangelogBundleAmendService(logFactory, _fileSystem, configurationContext: configurationContext); - var normalizedAddFiles = add != null - ? ExpandCommaSeparated(add).Select(NormalizePath).ToList() - : []; - var normalizedRemoveFiles = remove != null - ? ExpandCommaSeparated(remove).Select(NormalizePath).ToList() - : []; + var normalizedAddFiles = add != null ? ExpandCommaSeparated(add).Select(NormalizePath).ToList() : []; + var normalizedRemoveFiles = remove != null ? ExpandCommaSeparated(remove).Select(NormalizePath).ToList() : []; if (normalizedAddFiles.Count == 0 && normalizedRemoveFiles.Count == 0) { @@ -1378,9 +1474,7 @@ public async Task BundleAmend( DryRun = dryRun }; - serviceInvoker.AddCommand(service, input, - async static (s, collector, state, ctx) => await s.AmendBundle(collector, state, ctx) - ); + serviceInvoker.AddCommand(service, input, static async (s, collector, state, ctx) => await s.AmendBundle(collector, state, ctx)); return await serviceInvoker.InvokeAsync(ctx); } @@ -1453,9 +1547,7 @@ public async Task EvaluatePr( BotName = botName }; - serviceInvoker.AddCommand(service, args, - async static (s, collector, state, ctx) => await s.EvaluatePr(collector, state, ctx) - ); + serviceInvoker.AddCommand(service, args, static async (s, collector, state, ctx) => await s.EvaluatePr(collector, state, ctx)); return await serviceInvoker.InvokeAsync(ctx); } @@ -1539,9 +1631,7 @@ public async Task PrepareArtifact( ExistingChangelogFilename = existingChangelogFilename }; - serviceInvoker.AddCommand(service, args, - async static (s, collector, state, ctx) => await s.PrepareArtifact(collector, state, ctx) - ); + serviceInvoker.AddCommand(service, args, static async (s, collector, state, ctx) => await s.PrepareArtifact(collector, state, ctx)); return await serviceInvoker.InvokeAsync(ctx); } @@ -1555,12 +1645,7 @@ async static (s, collector, state, ctx) => await s.PrepareArtifact(collector, st /// GitHub repository owner /// GitHub repository name [NoOptionsInjection] - public async Task EvaluateArtifact( - string metadata, - string owner, - string repo, - CancellationToken ct = default - ) + public async Task EvaluateArtifact(string metadata, string owner, string repo, CancellationToken ct = default) { var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); @@ -1569,21 +1654,17 @@ public async Task EvaluateArtifact( IGitHubPrService prService = new GitHubPrService(logFactory); var service = new ChangelogArtifactEvaluationService(logFactory, prService, githubActionsService, fs); - var args = new EvaluateArtifactArguments - { - MetadataPath = metadata, - Owner = owner, - Repo = repo - }; + var args = new EvaluateArtifactArguments { MetadataPath = metadata, Owner = owner, Repo = repo }; - serviceInvoker.AddCommand(service, args, - async static (s, collector, state, ctx) => await s.EvaluateArtifact(collector, state, ctx) + serviceInvoker.AddCommand( + service, + args, + static async (s, collector, state, ctx) => await s.EvaluateArtifact(collector, state, ctx) ); return await serviceInvoker.InvokeAsync(ctx); } - private static List ExpandCommaSeparated(string[]? values) { if (values is not { Length: > 0 }) @@ -1605,9 +1686,9 @@ private static string GetPathForConfig(string repoPath, string targetPath) var relativePath = Path.GetRelativePath(repoPath, targetPath); // Prefer relative path when it does not escape the repo (e.g. not ".." or "..\..") - var useRelative = !relativePath.StartsWith("..", StringComparison.Ordinal) && - !Path.IsPathRooted(relativePath) && - relativePath != targetPath; + var useRelative = !relativePath.StartsWith("..", StringComparison.Ordinal) + && !Path.IsPathRooted(relativePath) + && relativePath != targetPath; var pathForConfig = useRelative ? relativePath : targetPath; pathForConfig = pathForConfig.Replace('\\', '/'); @@ -1689,7 +1770,8 @@ public async Task Upload( // Resolve the authoring owner/repo/branch for entry keys: CLI flags > bundle.{owner,repo} // (changelog.yml) > git. The repo is reduced to a single path segment (owner/repo -> repo) for the // changelog/{org}/{repo}/{branch}/ key. - var (resolvedRepo, resolvedOwner, resolvedBranch) = await ResolveUploadRepoOwnerBranch(repo, owner, branch, resolvedConfig, resolvedDirectory, ctx); + var (resolvedRepo, resolvedOwner, resolvedBranch) = + await ResolveUploadRepoOwnerBranch(repo, owner, branch, resolvedConfig, resolvedDirectory, ctx); await using var serviceInvoker = new ServiceInvoker(collector); var service = new ChangelogUploadService(logFactory, _fileSystem, configurationContext); @@ -1705,9 +1787,7 @@ public async Task Upload( Branch = resolvedBranch, SkipEtagCheck = skipEtagCheck }; - serviceInvoker.AddCommand(service, args, - static async (s, c, state, ct) => await s.Upload(c, state, ct) - ); + serviceInvoker.AddCommand(service, args, static async (s, c, state, ct) => await s.Upload(c, state, ct)); return await serviceInvoker.InvokeAsync(ctx); } @@ -1747,16 +1827,8 @@ public async Task ScrubberAllowlist( } var service = new ScrubberAllowlistIdentityService(logFactory, new GitHubReleaseService(logFactory), _fileSystem); - var args = new ResolveScrubberAllowlistArguments - { - Owner = owner, - Repo = repo, - Tag = tag, - AssemblerPath = assemblerPath - }; - serviceInvoker.AddCommand(service, args, - static async (s, c, state, ct) => await s.ResolveDeployedAsync(c, state, ct) is not null - ); + var args = new ResolveScrubberAllowlistArguments { Owner = owner, Repo = repo, Tag = tag, AssemblerPath = assemblerPath }; + serviceInvoker.AddCommand(service, args, static async (s, c, state, ct) => await s.ResolveDeployedAsync(c, state, ct) is not null); return await serviceInvoker.InvokeAsync(ctx); } @@ -1807,17 +1879,26 @@ public async Task MigrateFromWeb( Versions = ExpandCommaSeparated(versions) }; - serviceInvoker.AddCommand(service, args, - static async (s, c, state, ct) => await s.MigrateFromWeb(c, state, ct) - ); + serviceInvoker.AddCommand(service, args, static async (s, c, state, ct) => await s.MigrateFromWeb(c, state, ct)); return await serviceInvoker.InvokeAsync(ctx); } /// Resolves the authoring repo/owner/branch for uploads (CLI flags > bundle.{repo,owner} > git); owner falls back to the owner/ prefix of repo () before git, reducing the repo to a single path segment. - private async Task<(string? Repo, string? Owner, string? Branch)> ResolveUploadRepoOwnerBranch(string? repoCli, string? ownerCli, string? branchCli, string? configPath, string? uploadDirectory, CancellationToken ctx) + private async Task<(string? Repo, string? Owner, string? Branch)> ResolveUploadRepoOwnerBranch( + string? repoCli, + string? ownerCli, + string? branchCli, + string? configPath, + string? uploadDirectory, + CancellationToken ctx + ) { - var bundleConfig = await new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem) - .LoadChangelogConfiguration(collector, configPath, ctx); + var bundleConfig = + await new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem).LoadChangelogConfiguration( + collector, + configPath, + ctx + ); // Anchor the git fallbacks to the upload source (config file or changelog directory), not the // process cwd, so an out-of-tree --config/--directory resolves the right origin and branch. Both @@ -1885,5 +1966,4 @@ private static string NormalizePath(string path) // Convert to absolute path (handles relative paths like ./file or ../file) return Path.GetFullPath(trimmedPath); } - } diff --git a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs index 6432c46ddb..f592980441 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs @@ -28,11 +28,7 @@ namespace Documentation.Builder.Commands.Codex; /// (codex.yml) lists which repositories to include and how to compose the portal. /// /// -internal sealed class CodexCommands( - ILoggerFactory logFactory, - IDiagnosticsCollector collector, - IConfigurationContext configurationContext -) +internal sealed class CodexCommands(ILoggerFactory logFactory, IDiagnosticsCollector collector, IConfigurationContext configurationContext) { /// Clone all repositories and build the portal in one step. /// @@ -52,7 +48,8 @@ public async Task CloneAndBuild( bool assumeCloned = false, [ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? output = null, bool serve = false, - CancellationToken ct = default) + CancellationToken ct = default + ) { await using var serviceInvoker = new ServiceInvoker(collector); var fs = new CodexFileSystem(config.FullName, output?.FullName); @@ -65,22 +62,30 @@ public async Task CloneAndBuild( var cloneService = new CodexCloneService(logFactory, linkIndexReader); CodexCloneResult? cloneResult = null; - serviceInvoker.AddCommand(cloneService, (codexContext, fetchLatest, assumeCloned), strict, + serviceInvoker.AddCommand( + cloneService, + (codexContext, fetchLatest, assumeCloned), + strict, async (s, col, state, c) => { cloneResult = await s.CloneAll(state.codexContext, state.fetchLatest, state.assumeCloned, c); return cloneResult.Checkouts.Count > 0; - }); + } + ); var buildService = new CodexBuildService(logFactory, configurationContext); - serviceInvoker.AddCommand(buildService, (codexContext, cloneResult, fs), strict, + serviceInvoker.AddCommand( + buildService, + (codexContext, cloneResult, fs), + strict, async (s, col, state, c) => { if (state.cloneResult == null) return false; var result = await s.BuildAll(state.codexContext, state.cloneResult, state.fs, c); return result.DocumentationSets.Count > 0; - }); + } + ); var result = await serviceInvoker.InvokeAsync(ct); @@ -106,7 +111,8 @@ public async Task Clone( bool strict = false, bool fetchLatest = false, bool assumeCloned = false, - CancellationToken ct = default) + CancellationToken ct = default + ) { await using var serviceInvoker = new ServiceInvoker(collector); var fs = new CodexFileSystem(config.FullName); @@ -117,12 +123,16 @@ public async Task Clone( using var linkIndexReader = new GitLinkIndexReader(environment); var cloneService = new CodexCloneService(logFactory, linkIndexReader); - serviceInvoker.AddCommand(cloneService, (codexContext, fetchLatest, assumeCloned), strict, + serviceInvoker.AddCommand( + cloneService, + (codexContext, fetchLatest, assumeCloned), + strict, async (s, col, state, c) => { var result = await s.CloneAll(state.codexContext, state.fetchLatest, state.assumeCloned, c); return result.Checkouts.Count > 0; - }); + } + ); return await serviceInvoker.InvokeAsync(ct); } @@ -138,7 +148,8 @@ public async Task Build( [Argument, Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "yml,yaml")] FileInfo config, bool strict = false, [ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? output = null, - CancellationToken ct = default) + CancellationToken ct = default + ) { await using var serviceInvoker = new ServiceInvoker(collector); var fs = new CodexFileSystem(config.FullName, output?.FullName); @@ -155,12 +166,16 @@ public async Task Build( } var buildService = new CodexBuildService(logFactory, configurationContext); - serviceInvoker.AddCommand(buildService, (codexContext, cloneResult, fs), strict, + serviceInvoker.AddCommand( + buildService, + (codexContext, cloneResult, fs), + strict, async (s, col, state, c) => { var result = await s.BuildAll(state.codexContext, state.cloneResult, state.fs, c); return result.DocumentationSets.Count > 0; - }); + } + ); return await serviceInvoker.InvokeAsync(ct); } @@ -171,7 +186,11 @@ public async Task Build( /// Path to the portal output. Defaults to .artifacts/codex/docs/. [NoOptionsInjection] - public async Task Serve(int port = 4000, [Existing, ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? path = null, CancellationToken ct = default) + public async Task Serve( + int port = 4000, + [Existing, ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? path = null, + CancellationToken ct = default + ) { var servePath = path?.FullName ?? Path.Join(Environment.CurrentDirectory, ".artifacts", "codex", "docs"); diff --git a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs index 58ebf2a72e..8706573f57 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs @@ -55,9 +55,10 @@ public async Task Index( } var service = new CodexIndexService(logFactory, configurationContext); - serviceInvoker.AddCommand(service, (codexContext, cloneResult, fs, es), - static async (s, col, state, c) => - await s.Index(state.codexContext, state.cloneResult, state.fs, state.es, c) + serviceInvoker.AddCommand( + service, + (codexContext, cloneResult, fs, es), + static async (s, col, state, c) => await s.Index(state.codexContext, state.cloneResult, state.fs, state.es, c) ); return await serviceInvoker.InvokeAsync(ct); diff --git a/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs index ad319adf44..76c5856cac 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs @@ -20,11 +20,7 @@ namespace Documentation.Builder.Commands.Codex; /// Sync built codex output to S3 using a two-step plan/apply workflow. -internal sealed class CodexSyncCommand( - IDiagnosticsCollector collector, - ILoggerFactory logFactory, - ICoreService githubActionsService -) +internal sealed class CodexSyncCommand(IDiagnosticsCollector collector, ILoggerFactory logFactory, ICoreService githubActionsService) { /// Compute a diff of what would change when deploying to S3 and write it to a plan file. /// @@ -50,13 +46,25 @@ public async Task Plan( [ExpandUserProfile, RejectSymbolicLinks] FileInfo? @out = null, float? deleteThreshold = null, string[]? exclude = null, - CancellationToken ct = default) + CancellationToken ct = default + ) { await using var serviceInvoker = new ServiceInvoker(collector); var (context, service) = LoadContext(config); var excludePatterns = exclude ?? []; - serviceInvoker.AddCommand(service, (context, s3BucketName, @out, deleteThreshold, excludePatterns), - static async (s, collector, state, ctx) => await s.Plan(collector, state.context, state.s3BucketName, state.@out?.FullName ?? "", state.deleteThreshold, state.excludePatterns, ctx) + serviceInvoker.AddCommand( + service, + (context, s3BucketName, @out, deleteThreshold, excludePatterns), + static async (s, collector, state, ctx) => + await s.Plan( + collector, + state.context, + state.s3BucketName, + state.@out?.FullName ?? "", + state.deleteThreshold, + state.excludePatterns, + ctx + ) ); return await serviceInvoker.InvokeAsync(ct); } @@ -75,12 +83,16 @@ public async Task Apply( [Argument, Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "yml,yaml")] FileInfo config, string s3BucketName, [Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "json,plan")] FileInfo planFile, - CancellationToken ct = default) + CancellationToken ct = default + ) { await using var serviceInvoker = new ServiceInvoker(collector); var (context, service) = LoadContext(config); - serviceInvoker.AddCommand(service, (context, s3BucketName, planFile), - static async (s, collector, state, ctx) => await s.Apply(collector, state.context, state.s3BucketName, state.planFile.FullName, ctx) + serviceInvoker.AddCommand( + service, + (context, s3BucketName, planFile), + static async (s, collector, state, ctx) => + await s.Apply(collector, state.context, state.s3BucketName, state.planFile.FullName, ctx) ); return await serviceInvoker.InvokeAsync(ct); } @@ -89,7 +101,9 @@ static async (s, collector, state, ctx) => await s.Apply(collector, state.contex { var fs = new CodexFileSystem(config.FullName); var codexConfig = CodexConfiguration.Load(fs.ConfigurationFile); - return (new CodexContext(codexConfig, fs.ConfigurationFile, collector, fs), - new IncrementalDeployService(logFactory, githubActionsService)); + return (new CodexContext(codexConfig, fs.ConfigurationFile, collector, fs), new IncrementalDeployService( + logFactory, + githubActionsService + )); } } diff --git a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs index 048dd20595..e3fe920579 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs @@ -17,10 +17,7 @@ namespace Documentation.Builder.Commands.Codex; /// Update CloudFront KeyValueStore redirects for a codex deployment. -internal sealed class CodexUpdateRedirectsCommand( - IDiagnosticsCollector collector, - ILoggerFactory logFactory -) +internal sealed class CodexUpdateRedirectsCommand(IDiagnosticsCollector collector, ILoggerFactory logFactory) { /// Push the codex redirects mapping to CloudFront's KeyValueStore. /// Run after codex build produces a redirects.json. @@ -32,7 +29,8 @@ public async Task UpdateRedirects( [Argument, Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "yml,yaml")] FileInfo config, string? environment = null, [Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "json")] FileInfo? redirectsFile = null, - CancellationToken ct = default) + CancellationToken ct = default + ) { await using var serviceInvoker = new ServiceInvoker(collector); @@ -40,14 +38,21 @@ public async Task UpdateRedirects( if (!CodexConfigurationLoader.TryLoad(fs.ConfigurationFile, config.FullName, collector, out var codexConfig)) return 1; - var resolvedEnvironment = environment - ?? codexConfig.Environment - ?? Environment.GetEnvironmentVariable("ENVIRONMENT") - ?? "internal"; + var resolvedEnvironment = environment ?? codexConfig.Environment ?? Environment.GetEnvironmentVariable("ENVIRONMENT") ?? "internal"; var service = new DeployUpdateRedirectsService(logFactory, fs); - serviceInvoker.AddCommand(service, (environment: resolvedEnvironment, redirectsFile, kvsNamePrefix: "codex", defaultRedirectsFile: ".artifacts/codex/docs/redirects.json"), - static async (s, col, state, c) => await s.UpdateRedirects(col, state.environment, state.redirectsFile?.FullName, state.kvsNamePrefix, state.defaultRedirectsFile, c) + serviceInvoker.AddCommand( + service, + (environment: resolvedEnvironment, redirectsFile, kvsNamePrefix: "codex", defaultRedirectsFile: ".artifacts/codex/docs/redirects.json"), + static async (s, col, state, c) => + await s.UpdateRedirects( + col, + state.environment, + state.redirectsFile?.FullName, + state.kvsNamePrefix, + state.defaultRedirectsFile, + c + ) ); return await serviceInvoker.InvokeAsync(ct); } diff --git a/src/tooling/docs-builder/Commands/DiffCommands.cs b/src/tooling/docs-builder/Commands/DiffCommands.cs index 6a7f0d216f..b6843f7590 100644 --- a/src/tooling/docs-builder/Commands/DiffCommands.cs +++ b/src/tooling/docs-builder/Commands/DiffCommands.cs @@ -14,11 +14,7 @@ namespace Documentation.Builder.Commands; -internal sealed class DiffCommand( - ILoggerFactory logFactory, - IDiagnosticsCollector collector, - IConfigurationContext configurationContext -) +internal sealed class DiffCommand(ILoggerFactory logFactory, IDiagnosticsCollector collector, IConfigurationContext configurationContext) { /// Verify every renamed or removed page in the current branch has a redirect entry. /// @@ -35,8 +31,10 @@ public async Task Validate(string? path = null, CancellationToken ct = defa var service = new LocalChangeTrackingService(logFactory, configurationContext); var fs = DocumentationFileSystem.Resolve(path ?? Paths.WorkingDirectoryRoot.FullName); - serviceInvoker.AddCommand(service, (path, fs), - async static (s, collector, state, _) => await s.ValidateRedirects(collector, state.path, state.fs) + serviceInvoker.AddCommand( + service, + (path, fs), + static async (s, collector, state, _) => await s.ValidateRedirects(collector, state.path, state.fs) ); return await serviceInvoker.InvokeAsync(ct); } diff --git a/src/tooling/docs-builder/Commands/InboundLinkCommands.cs b/src/tooling/docs-builder/Commands/InboundLinkCommands.cs index 0429771102..72c0f0e78c 100644 --- a/src/tooling/docs-builder/Commands/InboundLinkCommands.cs +++ b/src/tooling/docs-builder/Commands/InboundLinkCommands.cs @@ -43,7 +43,9 @@ public async Task ValidateAll(CancellationToken ct = default) public async Task Validate(string? from = null, string? to = null, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); - serviceInvoker.AddCommand(_linkIndexService, (to, from), + serviceInvoker.AddCommand( + _linkIndexService, + (to, from), static async (s, collector, state, ctx) => await s.CheckRepository(collector, state.to, state.from, ctx) ); return await serviceInvoker.InvokeAsync(ct); @@ -57,10 +59,16 @@ static async (s, collector, state, ctx) => await s.CheckRepository(collector, st /// Path to links.json. Defaults to .artifacts/docs/html/links.json. /// -p, Root of the documentation source. Defaults to cwd. [NoOptionsInjection] - public async Task ValidateLinkReference([Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "json")] FileInfo? file = null, string? path = null, CancellationToken ct = default) + public async Task ValidateLinkReference( + [Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "json")] FileInfo? file = null, + string? path = null, + CancellationToken ct = default + ) { await using var serviceInvoker = new ServiceInvoker(collector); - serviceInvoker.AddCommand(_linkIndexService, (file, path), + serviceInvoker.AddCommand( + _linkIndexService, + (file, path), static async (s, collector, state, ctx) => await s.CheckWithLocalLinksJson(collector, state.file?.FullName, state.path, ctx) ); return await serviceInvoker.InvokeAsync(ct); diff --git a/src/tooling/docs-builder/Commands/IndexCommand.cs b/src/tooling/docs-builder/Commands/IndexCommand.cs index 35bfad9f4d..2db0dbfc19 100644 --- a/src/tooling/docs-builder/Commands/IndexCommand.cs +++ b/src/tooling/docs-builder/Commands/IndexCommand.cs @@ -43,9 +43,7 @@ public async Task Index( await using var serviceInvoker = new ServiceInvoker(collector); var fs = DocumentationFileSystem.Resolve(path ?? Paths.WorkingDirectoryRoot.FullName); var service = new IsolatedIndexService(logFactory, configurationContext, githubActionsService, environmentVariables); - serviceInvoker.AddCommand(service, - async (s, col, ctx) => await s.Index(col, fs, es, path, ctx) - ); + serviceInvoker.AddCommand(service, async (s, col, ctx) => await s.Index(col, fs, es, path, ctx)); return await serviceInvoker.InvokeAsync(ct); } } diff --git a/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs b/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs index 32c61c2227..b9b7cbf991 100644 --- a/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs +++ b/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs @@ -46,7 +46,10 @@ public async Task Build( IFileSystem? writeFs = inMemory ? new MockFileSystem() : null; var strictCommand = service.IsStrict(options.Strict); - serviceInvoker.AddCommand(service, (options, writeFs), strictCommand, + serviceInvoker.AddCommand( + service, + (options, writeFs), + strictCommand, static async (s, col, state, ctx) => await s.Build(col, state.options, state.writeFs, ctx) ); return await serviceInvoker.InvokeAsync(ct); diff --git a/src/tooling/docs-builder/Commands/MoveCommand.cs b/src/tooling/docs-builder/Commands/MoveCommand.cs index a347fc2f27..ffee455453 100644 --- a/src/tooling/docs-builder/Commands/MoveCommand.cs +++ b/src/tooling/docs-builder/Commands/MoveCommand.cs @@ -43,8 +43,11 @@ public async Task Move( var service = new MoveFileService(logFactory, configurationContext); var fs = DocumentationFileSystem.Resolve(path ?? Paths.WorkingDirectoryRoot.FullName); - serviceInvoker.AddCommand(service, (source, target, dryRun, path, fs), - async static (s, collector, state, ctx) => await s.Move(collector, state.source, state.target, state.dryRun, state.path, state.fs, ctx) + serviceInvoker.AddCommand( + service, + (source, target, dryRun, path, fs), + static async (s, collector, state, ctx) => + await s.Move(collector, state.source, state.target, state.dryRun, state.path, state.fs, ctx) ); return await serviceInvoker.InvokeAsync(ct); } @@ -57,13 +60,7 @@ async static (s, collector, state, ctx) => await s.Move(collector, state.source, [CommandIntent(Intent.Idempotent)] [MutationScope(MutationScope.Directory)] [CommandName("format")] - public async Task Format( - GlobalCliOptions _, - string? path = null, - bool check = false, - bool write = false, - Cancel ct = default - ) + public async Task Format(GlobalCliOptions _, string? path = null, bool check = false, bool write = false, Cancel ct = default) { if (check == write) { @@ -76,8 +73,10 @@ public async Task Format( var service = new FormatService(logFactory, configurationContext); var fs = DocumentationFileSystem.Resolve(path ?? Paths.WorkingDirectoryRoot.FullName); - serviceInvoker.AddCommand(service, (path, check, fs), - async static (s, collector, state, ctx) => await s.Format(collector, state.path, state.check, state.fs, ctx) + serviceInvoker.AddCommand( + service, + (path, check, fs), + static async (s, collector, state, ctx) => await s.Format(collector, state.path, state.check, state.fs, ctx) ); return await serviceInvoker.InvokeAsync(ct); } diff --git a/src/tooling/docs-builder/Commands/ServeCommand.cs b/src/tooling/docs-builder/Commands/ServeCommand.cs index 2321d9fc71..da3ec4cb5d 100644 --- a/src/tooling/docs-builder/Commands/ServeCommand.cs +++ b/src/tooling/docs-builder/Commands/ServeCommand.cs @@ -23,11 +23,20 @@ internal sealed class ServeCommand(ILoggerFactory logFactory, IConfigurationCont /// Disable the diagnostics HUD and background validation builds [CommandName("serve")] - public async Task Serve(GlobalCliOptions _, [Existing, ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? path = null, int port = 3000, bool watch = false, bool noHud = false, CancellationToken ct = default) + public async Task Serve( + GlobalCliOptions _, + [Existing, ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? path = null, + int port = 3000, + bool watch = false, + bool noHud = false, + CancellationToken ct = default + ) { var host = new DocumentationWebHost(logFactory, path?.FullName, port, configurationContext, watch, noHud); await host.RunAsync(ct); - _logger.LogInformation("Find your documentation at http://localhost:{Port}/{Path}", port, + _logger.LogInformation( + "Find your documentation at http://localhost:{Port}/{Path}", + port, host.GeneratorState.Generator.DocumentationSet.FirstInterestingUrl.TrimStart('/') ); await host.StopAsync(ct); diff --git a/src/tooling/docs-builder/Diagnostics/Console/ConsoleDiagnosticsCollector.cs b/src/tooling/docs-builder/Diagnostics/Console/ConsoleDiagnosticsCollector.cs index 2e7c79ffa4..d938756748 100644 --- a/src/tooling/docs-builder/Diagnostics/Console/ConsoleDiagnosticsCollector.cs +++ b/src/tooling/docs-builder/Diagnostics/Console/ConsoleDiagnosticsCollector.cs @@ -10,8 +10,10 @@ namespace Documentation.Builder.Diagnostics.Console; -public class ConsoleDiagnosticsCollector(ILoggerFactory logFactory, ICoreService? githubActions = null) - : DiagnosticsCollector([new Log(logFactory.CreateLogger()), new GithubAnnotationOutput(githubActions)]) +public class ConsoleDiagnosticsCollector(ILoggerFactory logFactory, ICoreService? githubActions = null) : DiagnosticsCollector([ + new Log(logFactory.CreateLogger()), + new GithubAnnotationOutput(githubActions) +]) { private readonly List _errors = []; private readonly List _warnings = []; @@ -41,7 +43,9 @@ public override async Task StopAsync(Cancel cancellationToken) repository.WriteDiagnosticsToConsole(_errors, _warnings, _hints); AnsiConsole.WriteLine(); - AnsiConsole.Write(new Markup($" [bold red]{Errors} Errors[/] / [bold blue]{Warnings} Warnings[/] / [bold yellow]{Hints} Hints[/]")); + AnsiConsole.Write( + new Markup($" [bold red]{Errors} Errors[/] / [bold blue]{Warnings} Warnings[/] / [bold yellow]{Hints} Hints[/]") + ); AnsiConsole.WriteLine(); AnsiConsole.WriteLine(); } diff --git a/src/tooling/docs-builder/Diagnostics/Console/ErrataFileSourceRepository.cs b/src/tooling/docs-builder/Diagnostics/Console/ErrataFileSourceRepository.cs index 4466f2ed53..1519890981 100644 --- a/src/tooling/docs-builder/Diagnostics/Console/ErrataFileSourceRepository.cs +++ b/src/tooling/docs-builder/Diagnostics/Console/ErrataFileSourceRepository.cs @@ -25,9 +25,7 @@ public override Markup Format(Errata.Diagnostic diagnostic) if (diagnostic.Category != null) { - _ = builder.Append("[b]") - .Append(diagnostic.Category.EscapeMarkup()) - .Append("[/]"); + _ = builder.Append("[b]").Append(diagnostic.Category.EscapeMarkup()).Append("[/]"); } if (diagnostic.Code != null) @@ -40,20 +38,13 @@ public override Markup Format(Errata.Diagnostic diagnostic) _ = builder.Append("]]"); } - if (!string.IsNullOrWhiteSpace(diagnostic.Category) - || !string.IsNullOrWhiteSpace(diagnostic.Code)) + if (!string.IsNullOrWhiteSpace(diagnostic.Category) || !string.IsNullOrWhiteSpace(diagnostic.Code)) _ = builder.Append("[white]: [/]"); var i = 0; foreach (var line in diagnostic.Message.EscapeMarkup().Split('\n')) { - _ = i == 0 - ? builder.Append("[white]") - .Append(line) - .Append("[/]") - : builder.AppendLine("[white]") - .Append(line) - .Append("[/]"); + _ = i == 0 ? builder.Append("[white]").Append(line).Append("[/]") : builder.AppendLine("[white]").Append(line).Append("[/]"); i++; } @@ -97,7 +88,11 @@ public bool TryGet(string id, [NotNullWhen(true)] out Source? source) return true; } - public void WriteDiagnosticsToConsole(IReadOnlyCollection errors, IReadOnlyCollection warnings, List hints) + public void WriteDiagnosticsToConsole( + IReadOnlyCollection errors, + IReadOnlyCollection warnings, + List hints + ) { // Fileless/global diagnostics (File == "") have no source location and no Errata label. // Errata's Report.Render collapses embedded newlines in the message headline, making @@ -111,9 +106,17 @@ public void WriteDiagnosticsToConsole(IReadOnlyCollection errors, IR var fileWarnings = warnings.Where(d => !string.IsNullOrEmpty(d.File)).ToArray(); var report = new Report(this); - var limited = fileErrors - .Concat(fileWarnings) - .OrderBy(d => d.Severity switch { Severity.Error => 0, Severity.Warning => 1, Severity.Hint => 2, _ => 3 }) + var limited = fileErrors.Concat(fileWarnings) + .OrderBy( + d => + d.Severity switch + { + Severity.Error => 0, + Severity.Warning => 1, + Severity.Hint => 2, + _ => 3 + } + ) .Take(100) .ToArray(); @@ -133,16 +136,17 @@ public void WriteDiagnosticsToConsole(IReadOnlyCollection errors, IR if (item is { Line: not null, Column: not null }) { var location = new Location(item.Line ?? 0, item.Column ?? 0); - d = d.WithLabel(new Label(item.File, location, "") - .WithLength(item.Length == null ? 1 : Math.Clamp(item.Length.Value, 1, item.Length.Value + 3)) - .WithPriority(1) - .WithColor(item.Severity switch - { - Severity.Error => Color.Red, - Severity.Warning => Color.Blue, - Severity.Hint => Color.Yellow, - _ => Color.Blue - })); + d = + d.WithLabel(new Label(item.File, location, "") + .WithLength(item.Length == null ? 1 : Math.Clamp(item.Length.Value, 1, item.Length.Value + 3)) + .WithPriority(1) + .WithColor(item.Severity switch + { + Severity.Error => Color.Red, + Severity.Warning => Color.Blue, + Severity.Hint => Color.Yellow, + _ => Color.Blue + })); } else d = d.WithNote(item.File); @@ -214,10 +218,7 @@ private static void DisplayHintsOnly(Report report, List hints) AnsiConsole.WriteLine(); AnsiConsole.WriteLine(); // Render the report - report.Render(AnsiConsole.Console, new ReportSettings - { - Formatter = new CustomDiagnosticsFormatter() - }); + report.Render(AnsiConsole.Console, new ReportSettings { Formatter = new CustomDiagnosticsFormatter() }); AnsiConsole.WriteLine(); AnsiConsole.WriteLine(); @@ -234,16 +235,15 @@ private static void DisplayErrorAndWarningSummary(Report report, int totalErrorC AnsiConsole.WriteLine(); AnsiConsole.WriteLine(); // Render the report - report.Render(AnsiConsole.Console, new ReportSettings - { - Formatter = new CustomDiagnosticsFormatter() - }); + report.Render(AnsiConsole.Console, new ReportSettings { Formatter = new CustomDiagnosticsFormatter() }); AnsiConsole.WriteLine(); AnsiConsole.WriteLine(); if (totalErrorCount > limited.Length) - AnsiConsole.Write(new Markup($" [bold]Only shown the first [yellow]{limited.Length}[/] diagnostics out of [yellow]{totalErrorCount}[/][/]")); + AnsiConsole.Write( + new Markup($" [bold]Only shown the first [yellow]{limited.Length}[/] diagnostics out of [yellow]{totalErrorCount}[/][/]") + ); AnsiConsole.WriteLine(); } diff --git a/src/tooling/docs-builder/Diagnostics/LiveMode/LiveModeDiagnosticsCollector.cs b/src/tooling/docs-builder/Diagnostics/LiveMode/LiveModeDiagnosticsCollector.cs index 27be77976f..0cceb5761b 100644 --- a/src/tooling/docs-builder/Diagnostics/LiveMode/LiveModeDiagnosticsCollector.cs +++ b/src/tooling/docs-builder/Diagnostics/LiveMode/LiveModeDiagnosticsCollector.cs @@ -8,8 +8,7 @@ namespace Documentation.Builder.Diagnostics.LiveMode; -public class LiveModeDiagnosticsCollector(ILoggerFactory logFactory) - : DiagnosticsCollector([new Log(logFactory.CreateLogger())]) +public class LiveModeDiagnosticsCollector(ILoggerFactory logFactory) : DiagnosticsCollector([new Log(logFactory.CreateLogger())]) { protected override void HandleItem(Diagnostic diagnostic) { } diff --git a/src/tooling/docs-builder/DocumentationTooling.cs b/src/tooling/docs-builder/DocumentationTooling.cs index 273fbf02da..c45211d3bf 100644 --- a/src/tooling/docs-builder/DocumentationTooling.cs +++ b/src/tooling/docs-builder/DocumentationTooling.cs @@ -26,44 +26,45 @@ public static class DocumentationTooling { public static TBuilder AddDocumentationToolingDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder { - _ = builder.Services - .AddGitHubActionsCore() - .AddSingleton(SystemEnvironmentVariables.Instance) - .AddSingleton() - .AddServiceDiscovery() - .ConfigureHttpClientDefaults(static client => - { - _ = client.AddServiceDiscovery(); - }) - .AddSingleton(sp => - { - var logFactory = sp.GetRequiredService(); - var githubActionsService = sp.GetRequiredService(); - return new ConsoleDiagnosticsCollector(logFactory, githubActionsService); - }) - .AddSingleton(_ => - { - var endpoints = ElasticsearchEndpointFactory.Create(builder.Configuration); - return endpoints; - }) - .AddSingleton(sp => - { - var endpoints = sp.GetRequiredService(); - var configurationFileProvider = sp.GetRequiredService(); - var versionsConfiguration = sp.GetRequiredService(); - var products = sp.GetRequiredService(); - var legacyUrlMappings = sp.GetRequiredService(); - var search = sp.GetRequiredService(); - return new ConfigurationContext + _ = + builder.Services + .AddGitHubActionsCore() + .AddSingleton(SystemEnvironmentVariables.Instance) + .AddSingleton() + .AddServiceDiscovery() + .ConfigureHttpClientDefaults(static client => { - ConfigurationFileProvider = configurationFileProvider, - VersionsConfiguration = versionsConfiguration, - Endpoints = endpoints, - ProductsConfiguration = products, - LegacyUrlMappings = legacyUrlMappings, - SearchConfiguration = search - }; - }); + _ = client.AddServiceDiscovery(); + }) + .AddSingleton(sp => + { + var logFactory = sp.GetRequiredService(); + var githubActionsService = sp.GetRequiredService(); + return new ConsoleDiagnosticsCollector(logFactory, githubActionsService); + }) + .AddSingleton(_ => + { + var endpoints = ElasticsearchEndpointFactory.Create(builder.Configuration); + return endpoints; + }) + .AddSingleton(sp => + { + var endpoints = sp.GetRequiredService(); + var configurationFileProvider = sp.GetRequiredService(); + var versionsConfiguration = sp.GetRequiredService(); + var products = sp.GetRequiredService(); + var legacyUrlMappings = sp.GetRequiredService(); + var search = sp.GetRequiredService(); + return new ConfigurationContext + { + ConfigurationFileProvider = configurationFileProvider, + VersionsConfiguration = versionsConfiguration, + Endpoints = endpoints, + ProductsConfiguration = products, + LegacyUrlMappings = legacyUrlMappings, + SearchConfiguration = search + }; + }); return builder; } diff --git a/src/tooling/docs-builder/Http/DocumentationWebHost.cs b/src/tooling/docs-builder/Http/DocumentationWebHost.cs index 43b9c869f9..7176b27cc4 100644 --- a/src/tooling/docs-builder/Http/DocumentationWebHost.cs +++ b/src/tooling/docs-builder/Http/DocumentationWebHost.cs @@ -43,7 +43,8 @@ public class DocumentationWebHost public InMemoryBuildState InMemoryBuildState { get; } - public DocumentationWebHost(ILoggerFactory logFactory, + public DocumentationWebHost( + ILoggerFactory logFactory, string? path, int port, IConfigurationContext configurationContext, @@ -58,13 +59,14 @@ public DocumentationWebHost(ILoggerFactory logFactory, builder.Services.AddElasticDocsApiServices("dev"); #endif - _ = builder.Logging - .AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Error) - .AddFilter("Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware", LogLevel.Error) - .AddFilter("Microsoft.AspNetCore.Routing.EndpointMiddleware", LogLevel.Warning) - .AddFilter("Microsoft.AspNetCore.Http.Result.ContentResult", LogLevel.Warning) - .AddFilter("Microsoft.AspNetCore.Http.Result.FileContentResult", LogLevel.Warning) - .AddFilter("Microsoft.Hosting.Lifetime", LogLevel.Information); + _ = + builder.Logging + .AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Error) + .AddFilter("Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware", LogLevel.Error) + .AddFilter("Microsoft.AspNetCore.Routing.EndpointMiddleware", LogLevel.Warning) + .AddFilter("Microsoft.AspNetCore.Http.Result.ContentResult", LogLevel.Warning) + .AddFilter("Microsoft.AspNetCore.Http.Result.FileContentResult", LogLevel.Warning) + .AddFilter("Microsoft.Hosting.Lifetime", LogLevel.Information); var collector = new LiveModeDiagnosticsCollector(logFactory); @@ -73,28 +75,35 @@ public DocumentationWebHost(ILoggerFactory logFactory, _hostedService = collector; var docFs = DocumentationFileSystem.Resolve(path, new DocumentationScopeOptions { InnerWrite = new MockFileSystem() }); _writeFileSystem = docFs.Write; - Context = new BuildContext(collector, docFs, configurationContext) - { - CanonicalBaseUrl = new Uri(hostUrl), - }; + Context = new BuildContext(collector, docFs, configurationContext) { CanonicalBaseUrl = new Uri(hostUrl), }; Context.Configuration.Features.DiagnosticsPanelEnabled = !noHud; InMemoryBuildState = new InMemoryBuildState(logFactory, configurationContext); - GeneratorState = new ReloadableGeneratorState(logFactory, Context.DocumentationSourceDirectory, Context.OutputDirectory, Context, isWatchBuild); - _ = builder.Services - .AddAotLiveReload(s => - { - s.FolderToMonitor = Context.DocumentationSourceDirectory.FullName; - s.ClientFileExtensions = ".md,.yml"; - }) - // Keep graceful-shutdown window short: SSE clients are signalled via ApplicationStopping - // (see RunAsync below) so there's no need to wait the default 30 s for them to drain. - .Configure(o => o.ShutdownTimeout = TimeSpan.FromSeconds(3)) - .AddSingleton(_ => GeneratorState) - .AddSingleton(_ => InMemoryBuildState) - .AddHostedService(sp => new ReloadGeneratorService(GeneratorState, InMemoryBuildState, noHud, logFactory.CreateLogger())); + GeneratorState = + new ReloadableGeneratorState(logFactory, Context.DocumentationSourceDirectory, Context.OutputDirectory, Context, isWatchBuild); + _ = + builder.Services + .AddAotLiveReload(s => + { + s.FolderToMonitor = Context.DocumentationSourceDirectory.FullName; + s.ClientFileExtensions = ".md,.yml"; + }) + // Keep graceful-shutdown window short: SSE clients are signalled via ApplicationStopping + // (see RunAsync below) so there's no need to wait the default 30 s for them to drain. + .Configure(o => o.ShutdownTimeout = TimeSpan.FromSeconds(3)) + .AddSingleton(_ => GeneratorState) + .AddSingleton(_ => InMemoryBuildState) + .AddHostedService( + sp => + new ReloadGeneratorService( + GeneratorState, + InMemoryBuildState, + noHud, + logFactory.CreateLogger() + ) + ); if (IsDotNetWatchBuild()) _ = builder.Services.AddHostedService(); @@ -130,31 +139,30 @@ public async Task StopAsync(Cancel ctx) private void SetUpRoutes() { - _ = _webApplication - .UseLiveReloadWithManualScriptInjection(_webApplication.Lifetime) - .UseDeveloperExceptionPage(new DeveloperExceptionPageOptions()) - .Use(async (context, next) => - { - try - { - await next(context); - } - catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) - { - // Client disconnected or navigated away — normal, no need to log or rethrow. - } - catch (Exception ex) + _ = + _webApplication.UseLiveReloadWithManualScriptInjection(_webApplication.Lifetime) + .UseDeveloperExceptionPage(new DeveloperExceptionPageOptions()) + .Use(async (context, next) => { - Console.WriteLine($"[UNHANDLED EXCEPTION] {ex.GetType().Name}: {ex.Message}"); - Console.WriteLine($"[STACK TRACE] {ex.StackTrace}"); - if (ex.InnerException != null) - Console.WriteLine($"[INNER EXCEPTION] {ex.InnerException.GetType().Name}: {ex.InnerException.Message}"); + try + { + await next(context); + } + catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) + { + // Client disconnected or navigated away — normal, no need to log or rethrow. + } + catch (Exception ex) + { + Console.WriteLine($"[UNHANDLED EXCEPTION] {ex.GetType().Name}: {ex.Message}"); + Console.WriteLine($"[STACK TRACE] {ex.StackTrace}"); + if (ex.InnerException != null) + Console.WriteLine($"[INNER EXCEPTION] {ex.InnerException.GetType().Name}: {ex.InnerException.Message}"); - throw; // Re-throw to let ASP.NET Core handle it - } - }) - .UseStaticFiles( - new StaticFileOptions + throw; // Re-throw to let ASP.NET Core handle it + } + }) + .UseStaticFiles(new StaticFileOptions { FileProvider = new EmbeddedOrPhysicalFileProvider(Context), RequestPath = "/_static" @@ -162,14 +170,19 @@ private void SetUpRoutes() _ = _webApplication.UseRouting(); - _ = _webApplication.MapGet("/", (ReloadableGeneratorState holder, Cancel ctx) => - ServeDocumentationFile(holder, "index", _writeFileSystem, ctx)); + _ = + _webApplication.MapGet( + "/", + (ReloadableGeneratorState holder, Cancel ctx) => ServeDocumentationFile(holder, "index", _writeFileSystem, ctx) + ); - _ = _webApplication.MapGet("/api/", (ReloadableGeneratorState holder, Cancel ctx) => - ServeApiFile(holder, "", ctx)); + _ = _webApplication.MapGet("/api/", (ReloadableGeneratorState holder, Cancel ctx) => ServeApiFile(holder, "", ctx)); - _ = _webApplication.MapGet("/api/{**slug}", (string slug, ReloadableGeneratorState holder, Cancel ctx) => - ServeApiFile(holder, slug, ctx)); + _ = + _webApplication.MapGet( + "/api/{**slug}", + (string slug, ReloadableGeneratorState holder, Cancel ctx) => ServeApiFile(holder, slug, ctx) + ); #if DEBUG var apiV1 = _webApplication.MapGroup($"{SystemEnvironmentVariables.Instance.ApiPrefix}/v1"); @@ -177,47 +190,60 @@ private void SetUpRoutes() #endif // SSE endpoint for diagnostics streaming - _ = _webApplication.MapGet("/_api/diagnostics/stream", async (InMemoryBuildState buildState, HttpContext context, Cancel ct) => - { - context.Response.Headers.Append("Content-Type", "text/event-stream"); - context.Response.Headers.Append("Cache-Control", "no-cache"); - context.Response.Headers.Append("Connection", "keep-alive"); + _ = + _webApplication.MapGet( + "/_api/diagnostics/stream", + async (InMemoryBuildState buildState, HttpContext context, Cancel ct) => + { + context.Response.Headers.Append("Content-Type", "text/event-stream"); + context.Response.Headers.Append("Cache-Control", "no-cache"); + context.Response.Headers.Append("Connection", "keep-alive"); - // Subscribe this client to receive broadcast events - var clientReader = buildState.Subscribe(); + // Subscribe this client to receive broadcast events + var clientReader = buildState.Subscribe(); - try - { - // Send initial state - var initialState = buildState.GetCurrentState(); - await WriteSSEEvent(context.Response, "state", initialState, ct); + try + { + // Send initial state + var initialState = buildState.GetCurrentState(); + await WriteSSEEvent(context.Response, "state", initialState, ct); - // Stream events as they occur (broadcast to all clients) - await foreach (var buildEvent in clientReader.ReadAllAsync(ct)) - { - await WriteSSEEvent(context.Response, buildEvent.Type, buildEvent, ct); + // Stream events as they occur (broadcast to all clients) + await foreach (var buildEvent in clientReader.ReadAllAsync(ct)) + { + await WriteSSEEvent(context.Response, buildEvent.Type, buildEvent, ct); + } + } + catch (OperationCanceledException) + { + // Client disconnected - this is expected, no need to log + } + finally + { + // Unsubscribe when client disconnects + buildState.Unsubscribe(clientReader); + } } - } - catch (OperationCanceledException) - { - // Client disconnected - this is expected, no need to log - } - finally - { - // Unsubscribe when client disconnects - buildState.Unsubscribe(clientReader); - } - }); + ); // Current state endpoint (non-streaming) - _ = _webApplication.MapGet("/_api/diagnostics/state", (InMemoryBuildState buildState) => - Results.Json(buildState.GetCurrentState(), DiagnosticsJsonContext.Default.BuildEvent)); - - _ = _webApplication.MapGet("/_static/pagefind/{**path}", (string path, InMemoryBuildState buildState, ReloadableGeneratorState holder) => - ServePagefindFile(path, buildState, holder)); - - _ = _webApplication.MapGet("{**slug}", (string slug, ReloadableGeneratorState holder, Cancel ctx) => - ServeDocumentationFile(holder, slug, _writeFileSystem, ctx)); + _ = + _webApplication.MapGet( + "/_api/diagnostics/state", + (InMemoryBuildState buildState) => Results.Json(buildState.GetCurrentState(), DiagnosticsJsonContext.Default.BuildEvent) + ); + + _ = + _webApplication.MapGet( + "/_static/pagefind/{**path}", + (string path, InMemoryBuildState buildState, ReloadableGeneratorState holder) => ServePagefindFile(path, buildState, holder) + ); + + _ = + _webApplication.MapGet( + "{**slug}", + (string slug, ReloadableGeneratorState holder, Cancel ctx) => ServeDocumentationFile(holder, slug, _writeFileSystem, ctx) + ); } private static IResult ServePagefindFile(string path, InMemoryBuildState buildState, ReloadableGeneratorState holder) @@ -271,7 +297,8 @@ private async Task ServeApiFile(ReloadableGeneratorState holder, string return Results.Text( "API generation in progress, please retry", contentType: "text/plain", - statusCode: StatusCodes.Status503ServiceUnavailable); + statusCode: StatusCodes.Status503ServiceUnavailable + ); } var apiRoot = Path.GetFullPath(holder.ApiPath.FullName); @@ -289,7 +316,12 @@ private async Task ServeApiFile(ReloadableGeneratorState holder, string return Results.NotFound(); } - private static async Task ServeDocumentationFile(ReloadableGeneratorState holder, string slug, ScopedFileSystem writeFs, Cancel ctx) + private static async Task ServeDocumentationFile( + ReloadableGeneratorState holder, + string slug, + ScopedFileSystem writeFs, + Cancel ctx + ) { if (slug == ".well-known/appspecific/com.chrome.devtools.json") return Results.NotFound(); @@ -310,7 +342,8 @@ private static async Task ServeDocumentationFile(ReloadableGeneratorSta // Path.GetExtension treats version segments like "8.19" as having extension ".19". // Only treat the slug as a bare file path when the extension is a known document type. var slugExt = Path.GetExtension(slug); - var hasKnownExtension = slugExt is ".md" or ".html" or ".json" or ".js" or ".css" or ".svg" or ".png" or ".jpg" or ".jpeg" or ".gif" or ".ico" or ".webp"; + var hasKnownExtension = + slugExt is ".md" or ".html" or ".json" or ".js" or ".css" or ".svg" or ".png" or ".jpg" or ".jpeg" or ".gif" or ".ico" or ".webp"; var s = !hasKnownExtension ? Path.Join(slug, "index.md") : slug; var fp = new FilePath(s, generator.DocumentationSet.SourceDirectory); @@ -341,7 +374,6 @@ private static async Task ServeDocumentationFile(ReloadableGeneratorSta // Regular HTML rendering var rendered = await generator.RenderLayout(markdown, ctx); return LiveReloadHtml(rendered.Html); - case ImageFile image: return Results.File(image.SourceFile.FullName, image.MimeType); default: diff --git a/src/tooling/docs-builder/Http/GcpIdTokenGenerator.cs b/src/tooling/docs-builder/Http/GcpIdTokenGenerator.cs index 139e47b21a..5f9ab567b4 100644 --- a/src/tooling/docs-builder/Http/GcpIdTokenGenerator.cs +++ b/src/tooling/docs-builder/Http/GcpIdTokenGenerator.cs @@ -24,30 +24,31 @@ string ClientX509CertUrl internal readonly record struct JwtHeader(string Alg, string Typ, string Kid); -internal readonly record struct JwtPayload( - string Iss, - string Sub, - string Aud, - long Iat, - long Exp, - string TargetAudience -); +internal readonly record struct JwtPayload(string Iss, string Sub, string Aud, long Iat, long Exp, string TargetAudience); [JsonSerializable(typeof(ServiceAccountKey))] [JsonSerializable(typeof(JwtPayload))] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)] -internal sealed partial class GcpJsonContext : JsonSerializerContext { } +internal sealed partial class GcpJsonContext : JsonSerializerContext +{ +} [JsonSerializable(typeof(JwtHeader))] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] -internal sealed partial class JwtHeaderJsonContext : JsonSerializerContext { } +internal sealed partial class JwtHeaderJsonContext : JsonSerializerContext +{ +} // This is a custom implementation to create an ID token for GCP. // Because Google.Api.Auth.OAuth2 is not compatible with AOT public static class GcpIdTokenGenerator { - public static async Task GenerateIdTokenAsync(string serviceAccountKeyPath, string targetAudience, CancellationToken cancellationToken = default) + public static async Task GenerateIdTokenAsync( + string serviceAccountKeyPath, + string targetAudience, + CancellationToken cancellationToken = default + ) { // Read and parse service account key file using System.Text.Json source generation (AOT compatible) var serviceAccountJson = await File.ReadAllTextAsync(serviceAccountKeyPath, cancellationToken); @@ -66,6 +67,7 @@ public static async Task GenerateIdTokenAsync(string serviceAccountKeyPa "https://oauth2.googleapis.com/token", now, now + 3600, // 1 hour expiration + targetAudience ); diff --git a/src/tooling/docs-builder/Http/InMemoryBuildState.cs b/src/tooling/docs-builder/Http/InMemoryBuildState.cs index c1d2463259..745a5d4c94 100644 --- a/src/tooling/docs-builder/Http/InMemoryBuildState.cs +++ b/src/tooling/docs-builder/Http/InMemoryBuildState.cs @@ -52,8 +52,11 @@ public class InMemoryBuildState(ILoggerFactory loggerFactory, IConfigurationCont // Capacity-1 bounded channel: a new trigger while a build is running queues one more run; // additional triggers drop the queued one (DropOldest) so only the latest matters. // The build loop (RunAsync) is the sole reader and never cancels a running build on file events. - private readonly Channel _buildChannel = Channel.CreateBounded( - new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true }); + private readonly Channel _buildChannel = Channel.CreateBounded(new BoundedChannelOptions(1) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true + }); private readonly Lock _diagnosticsLock = new(); private readonly List _diagnostics = []; @@ -82,11 +85,7 @@ public class InMemoryBuildState(ILoggerFactory loggerFactory, IConfigurationCont /// public ChannelReader Subscribe() { - var channel = Channel.CreateUnbounded(new UnboundedChannelOptions - { - SingleReader = true, - SingleWriter = true - }); + var channel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }); lock (_clientsLock) { @@ -120,8 +119,7 @@ public void Unsubscribe(ChannelReader reader) /// it is replaced by this one (DropOldest). A running build is never cancelled; instead the /// new request waits in the single-slot queue and runs as soon as the current build finishes. /// - public void ScheduleBuild(string sourcePath) => - _ = _buildChannel.Writer.TryWrite(sourcePath); + public void ScheduleBuild(string sourcePath) => _ = _buildChannel.Writer.TryWrite(sourcePath); /// /// Runs the build loop until is cancelled. Only @@ -169,28 +167,35 @@ private async Task ExecuteBuildAsync(string sourcePath, Cancel ct) WriteFileSystem = new MockFileSystem(); _writeFsPath = sourcePath; } - var service = new IsolatedBuildService(_loggerFactory, _configurationContext, new NullCoreService(), SystemEnvironmentVariables.Instance); + var service = new IsolatedBuildService( + _loggerFactory, + _configurationContext, + new NullCoreService(), + SystemEnvironmentVariables.Instance + ); _logger.LogInformation("Starting in-memory validation build for {Path}", sourcePath); - _ = await service.Build( - streamingCollector, - new IsolatedBuildOptions - { - Path = new DirectoryInfo(sourcePath), - Force = true, - Strict = false, - AllowIndexing = false, - MetadataOnly = false, - // Validation-only: parse + emit diagnostics without LLM export, config copy, - // link-index, or redirect generation — none make sense for an in-memory build. - Exporters = ExportOptions.Validation, - SkipApi = true, - SkipCrossLinks = false - }, - WriteFileSystem, // reuse MockFileSystem across builds for caching; initialized above - ct - ); + _ = + await service.Build( + streamingCollector, + new IsolatedBuildOptions + { + Path = new DirectoryInfo(sourcePath), + Force = true, + Strict = false, + AllowIndexing = false, + MetadataOnly = false, + // Validation-only: parse + emit diagnostics without LLM export, config copy, + // link-index, or redirect generation — none make sense for an in-memory build. + Exporters = ExportOptions.Validation, + SkipApi = true, + SkipCrossLinks = false + }, + WriteFileSystem, // reuse MockFileSystem across builds for caching; initialized above + + ct + ); // Stop the collector to complete the channel await streamingCollector.StopAsync(ct); @@ -198,16 +203,22 @@ private async Task ExecuteBuildAsync(string sourcePath, Cancel ct) Status = BuildStatus.Complete; // Emit build_complete event - await BroadcastEventAsync(new BuildEvent( - "build_complete", - DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - Errors: ErrorCount, - Warnings: WarningCount, - Hints: HintCount - )); - - _logger.LogInformation("In-memory build complete: {Errors} errors, {Warnings} warnings, {Hints} hints", - ErrorCount, WarningCount, HintCount); + await BroadcastEventAsync( + new BuildEvent( + "build_complete", + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + Errors: ErrorCount, + Warnings: WarningCount, + Hints: HintCount + ) + ); + + _logger.LogInformation( + "In-memory build complete: {Errors} errors, {Warnings} warnings, {Hints} hints", + ErrorCount, + WarningCount, + HintCount + ); } catch (OperationCanceledException) { @@ -223,13 +234,15 @@ await BroadcastEventAsync(new BuildEvent( Status = BuildStatus.Complete; // Emit build_complete with current counts even on error - await BroadcastEventAsync(new BuildEvent( - "build_complete", - DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - Errors: ErrorCount, - Warnings: WarningCount, - Hints: HintCount - )); + await BroadcastEventAsync( + new BuildEvent( + "build_complete", + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + Errors: ErrorCount, + Warnings: WarningCount, + Hints: HintCount + ) + ); } } @@ -309,16 +322,16 @@ public void CompleteAllClients() } } - public BuildEvent GetCurrentState() => new( - "state", - DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - Errors: ErrorCount, - Warnings: WarningCount, - Hints: HintCount, - Status: Status.ToString().ToLowerInvariant(), - Diagnostics: GetStoredDiagnostics() - ); - + public BuildEvent GetCurrentState() => + new( + "state", + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + Errors: ErrorCount, + Warnings: WarningCount, + Hints: HintCount, + Status: Status.ToString().ToLowerInvariant(), + Diagnostics: GetStoredDiagnostics() + ); public void Dispose() { @@ -338,8 +351,9 @@ public void Dispose() /// /// A diagnostics collector that streams diagnostics to the InMemoryBuildState /// - private sealed class StreamingDiagnosticsCollector(ILoggerFactory logFactory, InMemoryBuildState buildState) - : DiagnosticsCollector([new Log(logFactory.CreateLogger())]) + private sealed class StreamingDiagnosticsCollector(ILoggerFactory logFactory, InMemoryBuildState buildState) : DiagnosticsCollector([ + new Log(logFactory.CreateLogger()) + ]) { public override void Write(Diagnostic diagnostic) { @@ -361,11 +375,7 @@ public override void Write(Diagnostic diagnostic) buildState.StoreDiagnostic(dto); // Emit diagnostic event to all connected clients - var buildEvent = new BuildEvent( - "diagnostic", - DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - Diagnostic: dto - ); + var buildEvent = new BuildEvent("diagnostic", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), Diagnostic: dto); _ = buildState.BroadcastEventAsync(buildEvent); } @@ -392,7 +402,11 @@ private sealed class NullCoreService : ICoreService public string[] GetMultilineInput(string name, InputOptions? options = null) => []; public bool GetBoolInput(string name, InputOptions? options = null) => false; public Task SetOutputAsync(string name, string value) => Task.CompletedTask; - public ValueTask SetOutputAsync(string name, T value, System.Text.Json.Serialization.Metadata.JsonTypeInfo? jsonTypeInfo = null) => ValueTask.CompletedTask; + public ValueTask SetOutputAsync( + string name, + T value, + System.Text.Json.Serialization.Metadata.JsonTypeInfo? jsonTypeInfo = null + ) => ValueTask.CompletedTask; public ValueTask ExportVariableAsync(string name, string value) => ValueTask.CompletedTask; public void SetSecret(string secret) { } public ValueTask AddPathAsync(string inputPath) => ValueTask.CompletedTask; @@ -406,7 +420,11 @@ public void WriteInfo(string message) { } public void StartGroup(string name) { } public void EndGroup() { } public ValueTask GroupAsync(string name, Func> action) => action(); - public ValueTask SaveStateAsync(string name, T value, System.Text.Json.Serialization.Metadata.JsonTypeInfo? jsonTypeInfo = null) => ValueTask.CompletedTask; + public ValueTask SaveStateAsync( + string name, + T value, + System.Text.Json.Serialization.Metadata.JsonTypeInfo? jsonTypeInfo = null + ) => ValueTask.CompletedTask; public string GetState(string name) => string.Empty; public Summary Summary { get; } = new(); public bool IsDebug => false; diff --git a/src/tooling/docs-builder/Http/LiveReload.cs b/src/tooling/docs-builder/Http/LiveReload.cs index ca85a70dad..c9ce483729 100644 --- a/src/tooling/docs-builder/Http/LiveReload.cs +++ b/src/tooling/docs-builder/Http/LiveReload.cs @@ -14,6 +14,7 @@ // ReSharper disable once CheckNamespace #pragma warning disable IDE0130 namespace Westwind.AspNetCore.LiveReload; + #pragma warning restore IDE0130 // This exists to disable AOT trimming error messages for the LiveReload middleware's own AddLiveReload() method. @@ -63,20 +64,20 @@ public static IServiceCollection AddAotLiveReload(this IServiceCollection servic return services; } - public static IApplicationBuilder UseLiveReloadWithManualScriptInjection(this IApplicationBuilder builder, IHostApplicationLifetime webApplicationLifetime) + public static IApplicationBuilder UseLiveReloadWithManualScriptInjection( + this IApplicationBuilder builder, + IHostApplicationLifetime webApplicationLifetime + ) { var config = LiveReloadConfiguration.Current; if (config.LiveReloadEnabled) { - var webSocketOptions = new WebSocketOptions - { - KeepAliveInterval = TimeSpan.FromSeconds(300) - }; + var webSocketOptions = new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(300) }; _ = builder.UseWebSockets(webSocketOptions); - _ = builder - .Use((context, next) => + _ = + builder.Use((context, next) => { var middleWare = new NoInjectLiveReloadMiddleware(next, webApplicationLifetime); return middleWare.InvokeAsync(context); @@ -90,12 +91,14 @@ public static IApplicationBuilder UseLiveReloadWithManualScriptInjection(this IA } } - /// public class NoInjectLiveReloadMiddleware(RequestDelegate next, IHostApplicationLifetime lifeTime) : LiveReloadMiddleware(next, lifeTime) { private readonly MethodInfo _handleWebSocketRequest = - typeof(LiveReloadMiddleware).GetMethod("HandleWebSocketRequest", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.InvokeMethod)!; + typeof(LiveReloadMiddleware).GetMethod( + "HandleWebSocketRequest", + BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.InvokeMethod + )!; private readonly RequestDelegate _next = next; diff --git a/src/tooling/docs-builder/Http/ParcelWatchService.cs b/src/tooling/docs-builder/Http/ParcelWatchService.cs index e41c1d7fd8..8ebd28a317 100644 --- a/src/tooling/docs-builder/Http/ParcelWatchService.cs +++ b/src/tooling/docs-builder/Http/ParcelWatchService.cs @@ -14,16 +14,17 @@ public class ParcelWatchService : IHostedService public Task StartAsync(Cancel cancellationToken) { - _process = Process.Start(new ProcessStartInfo - { - FileName = "npm", - Arguments = "run watch", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, "src", "Elastic.Documentation.Site") - })!; + _process = + Process.Start(new ProcessStartInfo + { + FileName = "npm", + Arguments = "run watch", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, "src", "Elastic.Documentation.Site") + })!; _process.EnableRaisingEvents = true; _process.OutputDataReceived += (_, e) => Console.WriteLine($"[npm run watch]: {e.Data}"); diff --git a/src/tooling/docs-builder/Http/ReloadGeneratorService.cs b/src/tooling/docs-builder/Http/ReloadGeneratorService.cs index 4571640296..b9addf5477 100644 --- a/src/tooling/docs-builder/Http/ReloadGeneratorService.cs +++ b/src/tooling/docs-builder/Http/ReloadGeneratorService.cs @@ -15,12 +15,13 @@ public static class HotReloadManager { public static void ClearCache(Type[]? _) => LiveReloadMiddleware.RefreshWebSocketRequest(); - public static void UpdateApplication(Type[]? _) => Task.Run(async () => - { - await Task.Delay(1000); - var __ = LiveReloadMiddleware.RefreshWebSocketRequest(); - Console.WriteLine("UpdateApplication"); - }); + public static void UpdateApplication(Type[]? _) => + Task.Run(async () => + { + await Task.Delay(1000); + var __ = LiveReloadMiddleware.RefreshWebSocketRequest(); + Console.WriteLine("UpdateApplication"); + }); } public sealed class ReloadGeneratorService( @@ -32,8 +33,15 @@ ILogger logger { private static readonly FrozenSet AssetExtensions = new[] { - ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", - ".yml", ".yaml", ".toml" + ".png", + ".jpg", + ".jpeg", + ".gif", + ".svg", + ".webp", + ".yml", + ".yaml", + ".toml" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); private FileSystemWatcher? _watcher; @@ -65,13 +73,14 @@ public async Task StartAsync(Cancel cancellationToken) Logger.LogInformation("Start file watch on: {Directory}", directory); var watcher = new FileSystemWatcher(directory) { - NotifyFilter = NotifyFilters.Attributes - | NotifyFilters.CreationTime - | NotifyFilters.DirectoryName - | NotifyFilters.FileName - | NotifyFilters.LastWrite - | NotifyFilters.Security - | NotifyFilters.Size + NotifyFilter = + NotifyFilters.Attributes + | NotifyFilters.CreationTime + | NotifyFilters.DirectoryName + | NotifyFilters.FileName + | NotifyFilters.LastWrite + | NotifyFilters.Security + | NotifyFilters.Size }; watcher.Changed += OnChanged; @@ -97,20 +106,23 @@ public async Task StartAsync(Cancel cancellationToken) private void Reload(bool reloadConfiguration = false) { var token = _serviceCts?.Token ?? Cancel.None; - _debouncer.Schedule(async ctx => - { - await ReloadableGenerator.ReloadAsync(ctx, reloadConfiguration); - Logger.LogInformation("Reload complete!"); - _ = LiveReloadMiddleware.RefreshWebSocketRequest(); - - if (!noHud) + _debouncer.Schedule( + async ctx => { - // Schedule a validation build after every reload — both content edits and structural changes. - // The build loop coalesces rapid triggers: a new request while a build runs queues one more. - var sourcePath = ReloadableGenerator.Generator.Context.DocumentationCheckoutDirectory.FullName; - InMemoryBuildState.ScheduleBuild(sourcePath); - } - }, token); + await ReloadableGenerator.ReloadAsync(ctx, reloadConfiguration); + Logger.LogInformation("Reload complete!"); + _ = LiveReloadMiddleware.RefreshWebSocketRequest(); + + if (!noHud) + { + // Schedule a validation build after every reload — both content edits and structural changes. + // The build loop coalesces rapid triggers: a new request while a build runs queues one more. + var sourcePath = ReloadableGenerator.Generator.Context.DocumentationCheckoutDirectory.FullName; + InMemoryBuildState.ScheduleBuild(sourcePath); + } + }, + token + ); } public async Task StopAsync(Cancel cancellationToken) @@ -144,16 +156,18 @@ public async Task StopAsync(Cancel cancellationToken) // Check if a path should be ignored (output directories, hidden folders, etc.) private static bool ShouldIgnorePath(string path) => - path.Contains("/.artifacts/") || path.Contains("\\.artifacts\\") || - path.Contains("/_site/") || path.Contains("\\_site\\") || - path.Contains("/node_modules/") || path.Contains("\\node_modules\\") || - path.Contains("/.git/") || path.Contains("\\.git\\"); + path.Contains("/.artifacts/") + || path.Contains("\\.artifacts\\") + || path.Contains("/_site/") + || path.Contains("\\_site\\") + || path.Contains("/node_modules/") + || path.Contains("\\node_modules\\") + || path.Contains("/.git/") + || path.Contains("\\.git\\"); - private static bool IsConfigFile(string path) => - path.EndsWith("docset.yml") || path.EndsWith("toc.yml"); + private static bool IsConfigFile(string path) => path.EndsWith("docset.yml") || path.EndsWith("toc.yml"); - private static bool IsAssetFile(string path) => - AssetExtensions.Contains(Path.GetExtension(path)); + private static bool IsAssetFile(string path) => AssetExtensions.Contains(Path.GetExtension(path)); private void OnChanged(object sender, FileSystemEventArgs e) { @@ -219,8 +233,7 @@ private void OnRenamed(object sender, RenamedEventArgs e) #endif } - private void OnError(object sender, ErrorEventArgs e) => - PrintException(e.GetException()); + private void OnError(object sender, ErrorEventArgs e) => PrintException(e.GetException()); private void PrintException(Exception? ex) { @@ -260,15 +273,19 @@ public void Schedule(Func action, Cancel cancellationToken) newCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); _pendingCts = newCts; } - _ = Task.Run(async () => - { - try - { - await Task.Delay(window, newCts.Token); - await action(newCts.Token); - } - catch (OperationCanceledException) { } - }, newCts.Token); + _ = + Task.Run( + async () => + { + try + { + await Task.Delay(window, newCts.Token); + await action(newCts.Token); + } + catch (OperationCanceledException) { } + }, + newCts.Token + ); } public void Dispose() diff --git a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs index 7e667d6600..72d088f669 100644 --- a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs +++ b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs @@ -33,7 +33,8 @@ public class ReloadableGeneratorState : IDisposable private ILinkIndexReader? _codexReader; private FetchedCrossLinks? _cachedCrossLinks; - public ReloadableGeneratorState(ILoggerFactory logFactory, + public ReloadableGeneratorState( + ILoggerFactory logFactory, IDirectoryInfo sourcePath, IDirectoryInfo outputPath, BuildContext context, @@ -83,7 +84,8 @@ public async Task ReloadAsync(Cancel ctx, bool reloadConfiguration = true) _codexReader = _context.Configuration.Registry != DocSetRegistry.Public ? new GitLinkIndexReader(_context.Configuration.Registry.ToStringFast(true), new ApplicationDataFileSystem()) : null; - _crossLinkFetcher = new DocSetConfigurationCrossLinkFetcher(_logFactory, _context.Configuration, codexLinkIndexReader: _codexReader); + _crossLinkFetcher = + new DocSetConfigurationCrossLinkFetcher(_logFactory, _context.Configuration, codexLinkIndexReader: _codexReader); } var crossLinks = _cachedCrossLinks; if (crossLinks is null || reloadConfiguration) diff --git a/src/tooling/docs-builder/Http/StaticWebHost.cs b/src/tooling/docs-builder/Http/StaticWebHost.cs index 9b2638242e..74c17a5cb8 100644 --- a/src/tooling/docs-builder/Http/StaticWebHost.cs +++ b/src/tooling/docs-builder/Http/StaticWebHost.cs @@ -33,20 +33,18 @@ public StaticWebHost(int port, string? path) if (!dir.IsSubPathOf(fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName))) throw new Exception($"Can not serve directory outside of: {Paths.WorkingDirectoryRoot.FullName}"); - var builder = WebApplication.CreateBuilder(new WebApplicationOptions - { - ContentRootPath = _contentRoot - }); + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { ContentRootPath = _contentRoot }); _ = builder.AddDocumentationServiceDefaults(); #if DEBUG builder.Services.AddElasticDocsApiServices("dev"); #endif - _ = builder.Logging - .AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Error) - .AddFilter("Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware", LogLevel.Error) - .AddFilter("Microsoft.Hosting.Lifetime", LogLevel.Information); + _ = + builder.Logging + .AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Error) + .AddFilter("Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware", LogLevel.Error) + .AddFilter("Microsoft.Hosting.Lifetime", LogLevel.Information); _ = builder.WebHost.UseUrls($"http://localhost:{port}"); WebApplication = builder.Build(); @@ -59,36 +57,33 @@ public StaticWebHost(int port, string? path) private void SetUpRoutes() { - _ = WebApplication.Use(async (context, next) => - { - try - { - await next(context); - } - catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) - { - // Client disconnected or navigated away — normal, no need to log or rethrow. - } - catch (Exception ex) - { - Console.WriteLine($"[UNHANDLED EXCEPTION] {ex.GetType().Name}: {ex.Message}"); - Console.WriteLine($"[STACK TRACE] {ex.StackTrace}"); - if (ex.InnerException != null) - Console.WriteLine($"[INNER EXCEPTION] {ex.InnerException.GetType().Name}: {ex.InnerException.Message}"); - - throw; // Re-throw to let ASP.NET Core handle it - } - }); _ = - WebApplication - .UseDeveloperExceptionPage(new DeveloperExceptionPageOptions()) - .UseRouting(); + WebApplication.Use(async (context, next) => + { + try + { + await next(context); + } + catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) + { + // Client disconnected or navigated away — normal, no need to log or rethrow. + } + catch (Exception ex) + { + Console.WriteLine($"[UNHANDLED EXCEPTION] {ex.GetType().Name}: {ex.Message}"); + Console.WriteLine($"[STACK TRACE] {ex.StackTrace}"); + if (ex.InnerException != null) + Console.WriteLine($"[INNER EXCEPTION] {ex.InnerException.GetType().Name}: {ex.InnerException.Message}"); + + throw; // Re-throw to let ASP.NET Core handle it + } + }); + _ = WebApplication.UseDeveloperExceptionPage(new DeveloperExceptionPageOptions()).UseRouting(); _ = WebApplication.MapGet("/", ServeRootIndex); _ = WebApplication.MapGet("{**slug}", ServeDocumentationFile); - #if DEBUG var apiV1 = WebApplication.MapGroup($"{SystemEnvironmentVariables.Instance.ApiPrefix}/v1"); apiV1.MapElasticDocsApiEndpoints(); @@ -146,7 +141,6 @@ private async Task ServeDocumentationFile(string slug, Cancel _) return Results.File(fileInfo.FullName, mimetype); } - return Results.NotFound(); } } diff --git a/src/tooling/docs-builder/Middleware/CatchExceptionMiddleware.cs b/src/tooling/docs-builder/Middleware/CatchExceptionMiddleware.cs index 0dbc8f6d37..f8d070ed97 100644 --- a/src/tooling/docs-builder/Middleware/CatchExceptionMiddleware.cs +++ b/src/tooling/docs-builder/Middleware/CatchExceptionMiddleware.cs @@ -8,8 +8,10 @@ namespace Documentation.Builder.Middleware; -internal sealed class CatchExceptionMiddleware(ILogger logger, IDiagnosticsCollector collector) - : ICommandMiddleware +internal sealed class CatchExceptionMiddleware( + ILogger logger, + IDiagnosticsCollector collector +) : ICommandMiddleware { private bool _cancelKeyPressed; diff --git a/src/tooling/docs-builder/Middleware/CheckForUpdatesMiddleware.cs b/src/tooling/docs-builder/Middleware/CheckForUpdatesMiddleware.cs index cdf10b745b..61117e0c6b 100644 --- a/src/tooling/docs-builder/Middleware/CheckForUpdatesMiddleware.cs +++ b/src/tooling/docs-builder/Middleware/CheckForUpdatesMiddleware.cs @@ -67,7 +67,11 @@ private void CompareWithAssemblyVersion(Uri latestVersionUrl) return; _logger.LogInformation(""); - _logger.LogInformation("A new version of docs-builder is available: {Latest} (currently on {Current})", latestVersion, currentSemVersion); + _logger.LogInformation( + "A new version of docs-builder is available: {Latest} (currently on {Current})", + latestVersion, + currentSemVersion + ); _logger.LogInformation(" {LatestVersionUrl}", latestVersionUrl); _logger.LogInformation("Read more about updating: https://elastic.github.io/docs-builder/contribute/locally#step-one"); } diff --git a/src/tooling/docs-builder/Middleware/InfoLoggerMiddleware.cs b/src/tooling/docs-builder/Middleware/InfoLoggerMiddleware.cs index 8121f4ef08..229651879f 100644 --- a/src/tooling/docs-builder/Middleware/InfoLoggerMiddleware.cs +++ b/src/tooling/docs-builder/Middleware/InfoLoggerMiddleware.cs @@ -9,8 +9,10 @@ namespace Documentation.Builder.Middleware; -internal sealed class InfoLoggerMiddleware(ILogger logger, ConfigurationFileProvider fileProvider) - : ICommandMiddleware +internal sealed class InfoLoggerMiddleware( + ILogger logger, + ConfigurationFileProvider fileProvider +) : ICommandMiddleware { public async ValueTask InvokeAsync(CommandContext context, CommandMiddlewareDelegate next) { diff --git a/src/tooling/docs-builder/Program.cs b/src/tooling/docs-builder/Program.cs index 98eb0bc178..a81da12516 100644 --- a/src/tooling/docs-builder/Program.cs +++ b/src/tooling/docs-builder/Program.cs @@ -22,10 +22,13 @@ var argh = GlobalCliOptions.TryParseArgh(args, out var cliOptions); var builder = Host.CreateApplicationBuilder() - .AddDocumentationServiceDefaults(cliOptions ?? new GlobalCliOptions(), (s, p) => - { - _ = s.AddSingleton(AssemblyConfiguration.Create(p)); - }) + .AddDocumentationServiceDefaults( + cliOptions ?? new GlobalCliOptions(), + (s, p) => + { + _ = s.AddSingleton(AssemblyConfiguration.Create(p)); + } + ) .AddDocumentationToolingDefaults() .AddDocumentationOpenTelemetry(new OtelRegistration("docs-builder") { @@ -33,48 +36,60 @@ Tracing = (_, t) => t.AddSource(TelemetryConstants.AssemblerSyncInstrumentationName), }); -_ = builder.Services.AddArgh(args, app => -{ - _ = app.UseGlobalOptions(); +_ = + builder.Services.AddArgh( + args, + app => + { + _ = app.UseGlobalOptions(); - _ = app.UseMiddleware(); - _ = app.UseMiddleware(); - _ = app.UseMiddleware(); - _ = app.UseMiddleware(); + _ = app.UseMiddleware(); + _ = app.UseMiddleware(); + _ = app.UseMiddleware(); + _ = app.UseMiddleware(); - // `docs-builder build` as a named command AND root default (`docs-builder` with no sub-command). - _ = app.MapAndRootAlias(); + // `docs-builder build` as a named command AND root default (`docs-builder` with no sub-command). + _ = app.MapAndRootAlias(); - _ = app.Map(); - _ = app.Map(); - _ = app.Map(); - _ = app.Map(); - _ = app.MapNamespace("changelog"); - _ = app.MapNamespace("inbound-links"); + _ = app.Map(); + _ = app.Map(); + _ = app.Map(); + _ = app.Map(); + _ = app.MapNamespace("changelog"); + _ = app.MapNamespace("inbound-links"); - _ = app.Map(); + _ = app.Map(); - // assembler commands (assemble merged into assembler default) - _ = app.MapNamespace("assembler", g => - { - _ = g.MapNamespace("content-source"); - _ = g.MapNamespace("deploy"); - _ = g.MapNamespace("bloom-filter"); - _ = g.MapNamespace("navigation"); - _ = g.MapNamespace("config"); - _ = g.Map(); - _ = g.Map(); - _ = g.Map(); - }); + // assembler commands (assemble merged into assembler default) + _ = + app.MapNamespace( + "assembler", + g => + { + _ = g.MapNamespace("content-source"); + _ = g.MapNamespace("deploy"); + _ = g.MapNamespace("bloom-filter"); + _ = g.MapNamespace("navigation"); + _ = g.MapNamespace("config"); + _ = g.Map(); + _ = g.Map(); + _ = g.Map(); + } + ); - // codex commands - _ = app.MapNamespace("codex", g => - { - _ = g.Map(); - _ = g.Map(); - _ = g.MapNamespace("sync"); - }); -}); + // codex commands + _ = + app.MapNamespace( + "codex", + g => + { + _ = g.Map(); + _ = g.Map(); + _ = g.MapNamespace("sync"); + } + ); + } + ); using var host = builder.Build(); await host.RunAsync(); diff --git a/src/tooling/docs-migrate/CloneCommand.cs b/src/tooling/docs-migrate/CloneCommand.cs index c3c0bf1a9f..26a20c5d90 100644 --- a/src/tooling/docs-migrate/CloneCommand.cs +++ b/src/tooling/docs-migrate/CloneCommand.cs @@ -73,14 +73,12 @@ public async Task Clone( } catch (Exception ex) when (ex is not OperationCanceledException) { - _logger.LogWarning("Failed to clone for {Prefix} {Version}: {Message}", - b.Prefix, version.VersionLabel, ex.Message); + _logger.LogWarning("Failed to clone for {Prefix} {Version}: {Message}", b.Prefix, version.VersionLabel, ex.Message); } } } - _logger.LogInformation("Cloned {RepoCount} repos, {BranchCount} worktrees", - clonedRepos.Count, clonedBranches.Count); + _logger.LogInformation("Cloned {RepoCount} repos, {BranchCount} worktrees", clonedRepos.Count, clonedBranches.Count); return 0; } } diff --git a/src/tooling/docs-migrate/ConvertCommand.cs b/src/tooling/docs-migrate/ConvertCommand.cs index d998f084d5..0d2adcf4ea 100644 --- a/src/tooling/docs-migrate/ConvertCommand.cs +++ b/src/tooling/docs-migrate/ConvertCommand.cs @@ -37,8 +37,12 @@ public async Task Convert( var opts = SharedOptions.ResolveFilterOptions(dir, majors, all, minVersion, book, minors); _logger.LogInformation( "Filter: majors={Majors}, minors={Minors}, all={All}, minVersion={MinVersion}, book={Book}", - opts.Majors, opts.Minors.HasValue ? opts.Minors.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "all", - opts.All, opts.MinVersion ?? (object)"any", opts.Book ?? "all"); + opts.Majors, + opts.Minors.HasValue ? opts.Minors.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "all", + opts.All, + opts.MinVersion ?? (object)"any", + opts.Book ?? "all" + ); var books = SharedOptions.FilterBooks(conf, opts.Book); @@ -88,8 +92,7 @@ public async Task Convert( versionEntries.Add(new TocEntry { Toc = versionLabel, Island = true }); convertedVersions.Add(versionLabel); - _logger.LogInformation("Wrote {PageCount} pages for {Prefix}/{Version}", - pages.Count, b.Prefix, versionLabel); + _logger.LogInformation("Wrote {PageCount} pages for {Prefix}/{Version}", pages.Count, b.Prefix, versionLabel); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -103,10 +106,7 @@ public async Task Convert( tocRefs.Add(b.Prefix); convertedBooks[b.Prefix] = convertedVersions; - YamlWriter.WriteTocYaml(Path.Combine(prefixDir, "toc.yml"), [ - new TocEntry { File = "index.md" }, - ..versionEntries - ]); + YamlWriter.WriteTocYaml(Path.Combine(prefixDir, "toc.yml"), [new TocEntry { File = "index.md" }, .. versionEntries]); WriteBookVersionIndex(prefixDir, b, convertedVersions); } @@ -122,7 +122,12 @@ public async Task Convert( } private async Task> ProcessBookVersion( - LegacyBook book, BranchRef version, SourceRepoManager repoManager, string workDir, CancellationToken ct) + LegacyBook book, + BranchRef version, + SourceRepoManager repoManager, + string workDir, + CancellationToken ct + ) { var versionLabel = version.VersionLabel; var sources = await repoManager.ResolveSourcesAsync(book, version, ct); @@ -175,18 +180,13 @@ private async Task> ProcessBookVersion( var parser = new AsciidocParser(parserOptions); var document = parser.Parse(content, basePath); - var emitterOptions = new MarkdownEmitterOptions - { - BookPrefix = book.Prefix, - Version = versionLabel - }; + var emitterOptions = new MarkdownEmitterOptions { BookPrefix = book.Prefix, Version = versionLabel }; var emitter = new MarkdownEmitter(emitterOptions); return PageChunker.Chunk(document, book.Chunk, emitter); } - private static async Task> WritePages( - IReadOnlyList pages, string directory, CancellationToken ct) + private static async Task> WritePages(IReadOnlyList pages, string directory, CancellationToken ct) { var entries = new List(); foreach (var page in pages) @@ -224,8 +224,7 @@ private static void WriteBookVersionIndex(string prefixDir, LegacyBook book, Lis File.WriteAllText(Path.Combine(prefixDir, "index.md"), sb.ToString()); } - private static void WriteGuideOverview( - string outputDir, LegacyConf conf, Dictionary> convertedBooks) + private static void WriteGuideOverview(string outputDir, LegacyConf conf, Dictionary> convertedBooks) { var sb = new StringBuilder(); _ = sb.AppendLine("# Elastic Docs"); @@ -233,9 +232,7 @@ private static void WriteGuideOverview( foreach (var category in conf.Contents) { - var categoryBooks = category.Sections - .Where(b => convertedBooks.ContainsKey(b.Prefix)) - .ToList(); + var categoryBooks = category.Sections.Where(b => convertedBooks.ContainsKey(b.Prefix)).ToList(); if (categoryBooks.Count == 0) continue; @@ -245,12 +242,19 @@ private static void WriteGuideOverview( foreach (var b in categoryBooks) { var versions = convertedBooks[b.Prefix]; - var current = !string.IsNullOrEmpty(b.Current) && versions.Contains(b.Current) - ? b.Current - : versions[0]; - _ = sb.Append("- [").Append(b.Title).Append(" [").Append(current).Append("]](") - .Append(b.Prefix).Append('/').Append(current).Append("/index.md) — [other versions](") - .Append(b.Prefix).AppendLine("/index.md)"); + var current = !string.IsNullOrEmpty(b.Current) && versions.Contains(b.Current) ? b.Current : versions[0]; + _ = + sb.Append("- [") + .Append(b.Title) + .Append(" [") + .Append(current) + .Append("]](") + .Append(b.Prefix) + .Append('/') + .Append(current) + .Append("/index.md) — [other versions](") + .Append(b.Prefix) + .AppendLine("/index.md)"); } _ = sb.AppendLine(); diff --git a/src/tooling/docs-migrate/Program.cs b/src/tooling/docs-migrate/Program.cs index 47b7fe8398..28a28f9cac 100644 --- a/src/tooling/docs-migrate/Program.cs +++ b/src/tooling/docs-migrate/Program.cs @@ -8,15 +8,18 @@ var builder = Host.CreateApplicationBuilder(args); -builder.Services.AddArgh(args, app => -{ - _ = app.UseCliDescription("docs-migrate — convert Elastic legacy AsciiDoc books to docs-builder Markdown."); - _ = app.Map(); - _ = app.Map(); - _ = app.Map(); - _ = app.Map(); - _ = app.Map(); -}); +builder.Services.AddArgh( + args, + app => + { + _ = app.UseCliDescription("docs-migrate — convert Elastic legacy AsciiDoc books to docs-builder Markdown."); + _ = app.Map(); + _ = app.Map(); + _ = app.Map(); + _ = app.Map(); + _ = app.Map(); + } +); using var host = builder.Build(); await host.RunAsync(); diff --git a/src/tooling/docs-migrate/ServeCommand.cs b/src/tooling/docs-migrate/ServeCommand.cs index 8cdf47f79c..1f78cc1b60 100644 --- a/src/tooling/docs-migrate/ServeCommand.cs +++ b/src/tooling/docs-migrate/ServeCommand.cs @@ -22,13 +22,22 @@ public async Task Serve(int port = 3001, CancellationToken ct = default) return 1; } - string[] args = ["run", "--project", "src/tooling/docs-builder", "--", "serve", "--path", outputDir, "--port", $"{port}", "--no-hud"]; + string[] args = + [ + "run", + "--project", + "src/tooling/docs-builder", + "--", + "serve", + "--path", + outputDir, + "--port", + $"{port}", + "--no-hud" + ]; Console.WriteLine($"dotnet {string.Join(' ', args)}"); - var arguments = new ExecArguments("dotnet", args) - { - WorkingDirectory = Paths.WorkingDirectoryRoot.FullName - }; + var arguments = new ExecArguments("dotnet", args) { WorkingDirectory = Paths.WorkingDirectoryRoot.FullName }; try { return await Proc.ExecAsync(arguments, ct); diff --git a/src/tooling/docs-migrate/SharedOptions.cs b/src/tooling/docs-migrate/SharedOptions.cs index c2b046d8a4..1b5ca4e56e 100644 --- a/src/tooling/docs-migrate/SharedOptions.cs +++ b/src/tooling/docs-migrate/SharedOptions.cs @@ -21,8 +21,7 @@ internal static class SharedOptions public static readonly DirectoryInfo DefaultWorkDir = ResolveDefaultWorkDir(); - public static string ResolveWorkDir(string? workDir) => - workDir ?? DefaultWorkDir.FullName; + public static string ResolveWorkDir(string? workDir) => workDir ?? DefaultWorkDir.FullName; private static DirectoryInfo ResolveDefaultWorkDir() { @@ -65,8 +64,7 @@ public static FilterOptions LoadFilterOptions(string workDir) return JsonSerializer.Deserialize(json, JsonOptions) ?? new FilterOptions(); } - public static FilterOptions ResolveFilterOptions( - string workDir, int? majors, bool? all, int? minVersion, string? book, int? minors) + public static FilterOptions ResolveFilterOptions(string workDir, int? majors, bool? all, int? minVersion, string? book, int? minors) { var saved = LoadFilterOptions(workDir); @@ -90,17 +88,15 @@ public static List FilterVersions(LegacyBook book, int majors, bool a var branches = book.Branches.ToList(); if (minVersion is not null) - branches = branches - .Where(b => TryParseMajorMinor(b.VersionLabel) is var p && p.HasValue && p.Value.Major >= minVersion) - .ToList(); + branches = + branches.Where(b => TryParseMajorMinor(b.VersionLabel) is var p && p.HasValue && p.Value.Major >= minVersion).ToList(); if (all) return EnsureCurrent(book, SortDescending(branches)); var selected = new HashSet(StringComparer.OrdinalIgnoreCase); - var grouped = branches - .Select(b => (Branch: b, Parsed: TryParseMajorMinor(b.VersionLabel))) + var grouped = branches.Select(b => (Branch: b, Parsed: TryParseMajorMinor(b.VersionLabel))) .Where(x => x.Parsed.HasValue) .GroupBy(x => x.Parsed!.Value.Major) .OrderByDescending(g => g.Key) @@ -126,16 +122,14 @@ private static List EnsureCurrent(LegacyBook book, List ve if (versions.Any(v => v.VersionLabel == book.Current)) return versions; - var currentBranch = book.Branches.FirstOrDefault(b => b.VersionLabel == book.Current) - ?? new BranchRef(book.Current); + var currentBranch = book.Branches.FirstOrDefault(b => b.VersionLabel == book.Current) ?? new BranchRef(book.Current); versions.Insert(0, currentBranch); return versions; } private static List SortDescending(IEnumerable branches) => - branches - .Select(b => (Branch: b, Parsed: TryParseMajorMinor(b.VersionLabel))) + branches.Select(b => (Branch: b, Parsed: TryParseMajorMinor(b.VersionLabel))) .OrderByDescending(x => x.Parsed?.Major ?? 0) .ThenByDescending(x => x.Parsed?.Minor ?? 0) .Select(x => x.Branch) diff --git a/src/tooling/essc/Commands/AiEnrichmentConsole.cs b/src/tooling/essc/Commands/AiEnrichmentConsole.cs index 7f24dd455c..9aa2d186dc 100644 --- a/src/tooling/essc/Commands/AiEnrichmentConsole.cs +++ b/src/tooling/essc/Commands/AiEnrichmentConsole.cs @@ -49,14 +49,13 @@ await AnsiConsole.Progress() switch (p.Phase) { case AiEnrichmentPhase.Querying when p.TotalCandidates > 0: - var effectiveMax = maxAiDocs > 0 - ? Math.Min(p.TotalCandidates, maxAiDocs) - : p.TotalCandidates; + var effectiveMax = maxAiDocs > 0 ? Math.Min(p.TotalCandidates, maxAiDocs) : p.TotalCandidates; task.IsIndeterminate = false; task.MaxValue = effectiveMax; task.Value = 0; - task.Description = $"[purple]Found {p.TotalCandidates:N0} candidates[/]" - + (maxAiDocs > 0 ? $" [dim](limit: {maxAiDocs:N0})[/]" : ""); + task.Description = + $"[purple]Found {p.TotalCandidates:N0} candidates[/]" + + (maxAiDocs > 0 ? $" [dim](limit: {maxAiDocs:N0})[/]" : ""); break; case AiEnrichmentPhase.Enriching: task.Value = p.Enriched + p.Failed; @@ -94,15 +93,15 @@ await AnsiConsole.Progress() sw.Stop(); - return new AiEnrichmentResult( - last?.Enriched ?? 0, - last?.Failed ?? 0, - last?.TotalCandidates ?? 0, - sw.Elapsed - ); + return new AiEnrichmentResult(last?.Enriched ?? 0, last?.Failed ?? 0, last?.TotalCandidates ?? 0, sw.Elapsed); } - internal static void DisplaySummary(AiEnrichmentResult? result, TimeSpan? maxAiTime, int maxAiDocs, string panelTitle = "[aqua]AI Enrichment Complete[/]") + internal static void DisplaySummary( + AiEnrichmentResult? result, + TimeSpan? maxAiTime, + int maxAiDocs, + string panelTitle = "[aqua]AI Enrichment Complete[/]" + ) { if (result is null) return; @@ -111,35 +110,21 @@ internal static void DisplaySummary(AiEnrichmentResult? result, TimeSpan? maxAiT var rows = new List(); - var aiGrid = new Grid() - .AddColumn(new GridColumn().NoWrap().PadRight(2)) - .AddColumn(new GridColumn().NoWrap()); + var aiGrid = new Grid().AddColumn(new GridColumn().NoWrap().PadRight(2)).AddColumn(new GridColumn().NoWrap()); - _ = aiGrid.AddRow( - new Markup("[purple]Candidates[/]"), - new Markup($"[white]{result.TotalCandidates:N0}[/]") - ); + _ = aiGrid.AddRow(new Markup("[purple]Candidates[/]"), new Markup($"[white]{result.TotalCandidates:N0}[/]")); if (result.Enriched > 0) { - _ = aiGrid.AddRow( - new Markup("[green] Enriched[/]"), - new Markup($"[white]{result.Enriched:N0}[/]") - ); + _ = aiGrid.AddRow(new Markup("[green] Enriched[/]"), new Markup($"[white]{result.Enriched:N0}[/]")); } if (result.Failed > 0) { - _ = aiGrid.AddRow( - new Markup("[red] Failed[/]"), - new Markup($"[white]{result.Failed:N0}[/]") - ); + _ = aiGrid.AddRow(new Markup("[red] Failed[/]"), new Markup($"[white]{result.Failed:N0}[/]")); } - _ = aiGrid.AddRow( - new Markup("[dim] Duration[/]"), - new Markup($"[white]{result.Duration:hh\\:mm\\:ss}[/]") - ); + _ = aiGrid.AddRow(new Markup("[dim] Duration[/]"), new Markup($"[white]{result.Duration:hh\\:mm\\:ss}[/]")); rows.Add(aiGrid); diff --git a/src/tooling/essc/Commands/ContentStackCommands.cs b/src/tooling/essc/Commands/ContentStackCommands.cs index f1da458db7..72c177a82c 100644 --- a/src/tooling/essc/Commands/ContentStackCommands.cs +++ b/src/tooling/essc/Commands/ContentStackCommands.cs @@ -58,8 +58,8 @@ public Task Sync( TimeSpan? maxAiTime = null, bool noIndex = false, [Range(0, int.MaxValue)] int pagePer = 0, - Cancel ct = default) => - sync.Sync(cacheFolder, apiKey, endpoint, force, noAi, maxAiDocs, maxAiTime, noIndex, pagePer, ct); + Cancel ct = default + ) => sync.Sync(cacheFolder, apiKey, endpoint, force, noAi, maxAiDocs, maxAiTime, noIndex, pagePer, ct); /// /// Discover all content types and whether each exposes a root URL field (sitemap and routing inputs). @@ -68,10 +68,7 @@ public Task Sync( /// Disk folder for the survey state file. /// Delete saved survey progress and re-run discovery. /// Cancellation token. - public Task Types( - [StringLength(4096)] string? cacheFolder = null, - bool force = false, - Cancel ct = default) => + public Task Types([StringLength(4096)] string? cacheFolder = null, bool force = false, Cancel ct = default) => types.Types(cacheFolder, force, ct); /// @@ -82,10 +79,7 @@ public Task Types( /// /// Directory for *.json files; created if it does not exist. /// Cancellation token. - public Task Samples( - [StringLength(4096)] string? outputDir = null, - Cancel ct = default) => - samples.Samples(outputDir, ct); + public Task Samples([StringLength(4096)] string? outputDir = null, Cancel ct = default) => samples.Samples(outputDir, ct); /// /// Diagnostic: scan Contentstack's sync stream (the same paginated, cursor-based API @@ -99,10 +93,7 @@ public Task Samples( /// Path fragment to search for, e.g. /elasticon/archive/2020. /// Restrict the scan to one content type uid; omit to scan all types. /// Cancellation token. - public Task FindUrl( - [Argument] string pathPrefix, - string? contentType = null, - Cancel ct = default) => + public Task FindUrl([Argument] string pathPrefix, string? contentType = null, Cancel ct = default) => findUrl.FindUrl(pathPrefix, contentType, ct); /// @@ -171,10 +162,13 @@ public async Task AiEnrich( await AnsiConsole.Status() .AutoRefresh(true) .Spinner(Spinner.Known.Dots) - .StartAsync("[aqua]Bootstrapping Elasticsearch indices...[/]", async _ => - { - await exporter.StartAsync(effectiveToken); - }); + .StartAsync( + "[aqua]Bootstrapping Elasticsearch indices...[/]", + async _ => + { + await exporter.StartAsync(effectiveToken); + } + ); AnsiConsole.MarkupLine($"[green]✓[/] Elasticsearch indices ready [dim]({exporter.Strategy})[/]"); AnsiConsole.WriteLine(); @@ -185,11 +179,13 @@ await AnsiConsole.Status() return; } - var aiResult = await AiEnrichmentConsole.RunInteractiveAsync( - exporter.AiEnrichmentEnabled, - (max, token) => exporter.RunAiEnrichmentAsync(max, token), - maxAiDocs, - effectiveToken); + var aiResult = + await AiEnrichmentConsole.RunInteractiveAsync( + exporter.AiEnrichmentEnabled, + (max, token) => exporter.RunAiEnrichmentAsync(max, token), + maxAiDocs, + effectiveToken + ); AiEnrichmentConsole.DisplaySummary(aiResult, maxAiTime, maxAiDocs); } catch (OperationCanceledException) when (deadline.TimedOut) diff --git a/src/tooling/essc/Commands/ContentTypesCommand.cs b/src/tooling/essc/Commands/ContentTypesCommand.cs index 737a1d5a16..e9ddebd47c 100644 --- a/src/tooling/essc/Commands/ContentTypesCommand.cs +++ b/src/tooling/essc/Commands/ContentTypesCommand.cs @@ -7,9 +7,7 @@ namespace Elastic.SiteSearch.Cli.Commands; -internal sealed class ContentTypesCommand( - ContentStackClient client -) +internal sealed class ContentTypesCommand(ContentStackClient client) { private const string StateFile = "content-types-state.json"; @@ -22,19 +20,14 @@ ContentStackClient client /// On-disk folder for the survey cache. /// Discard saved survey state and restart. /// Cancellation token. - public async Task Types( - string? cacheFolder = null, - bool force = false, - Cancel ct = default - ) + public async Task Types(string? cacheFolder = null, bool force = false, Cancel ct = default) { var store = new StateManager(cacheFolder); if (force) store.Delete(StateFile); - var state = store.Load(StateFile, StateJsonContext.Default.ContentTypesState) - ?? new ContentTypesState(); + var state = store.Load(StateFile, StateJsonContext.Default.ContentTypesState) ?? new ContentTypesState(); AnsiConsole.MarkupLine("[aqua bold]Contentstack Content Type Survey[/]"); AnsiConsole.MarkupLine($"[dim]Cache: {Markup.Escape(store.CacheFolder)}[/]"); @@ -44,7 +37,8 @@ public async Task Types( { AnsiConsole.MarkupLine( $"[dim]Loaded [white]{state.ContentTypes.Count}[/] previously discovered content types " + - $"([white]{state.TotalItemsSeen:N0}[/] items seen)[/]"); + $"([white]{state.TotalItemsSeen:N0}[/] items seen)[/]" + ); if (state.Completed) { @@ -67,39 +61,36 @@ await AnsiConsole.Progress() .AutoRefresh(true) .AutoClear(false) .HideCompleted(false) - .Columns( - new SpinnerColumn(), - new TaskDescriptionColumn(), - new ProgressBarColumn(), - new PercentageColumn() - ) + .Columns(new SpinnerColumn(), new TaskDescriptionColumn(), new ProgressBarColumn(), new PercentageColumn()) .StartAsync(async ctx => { var task = ctx.AddTask("[aqua]Syncing content[/]", maxValue: 100); task.IsIndeterminate = true; - _ = await client.InitialSyncAsync( - resumePaginationToken: state.PaginationToken, - progress: new Progress(p => - { - if (p.TotalCount > 0) + _ = + await client.InitialSyncAsync( + resumePaginationToken: state.PaginationToken, + progress: new Progress(p => { - task.IsIndeterminate = false; - task.MaxValue = p.TotalCount; - task.Value = p.ItemsSoFar; - } - task.Description = $"[aqua]Page {p.PagesCompleted}[/] — [white]{p.ItemsSoFar:N0}[/] items " + - $"([white]{state.ContentTypes.Count}[/] types)"; - }), - onPage: response => - { - ProcessPage(state, response); - state.PaginationToken = response.PaginationToken; - store.Save(StateFile, state, StateJsonContext.Default.ContentTypesState); - return Task.CompletedTask; - }, - ct: ct - ); + if (p.TotalCount > 0) + { + task.IsIndeterminate = false; + task.MaxValue = p.TotalCount; + task.Value = p.ItemsSoFar; + } + task.Description = + $"[aqua]Page {p.PagesCompleted}[/] — [white]{p.ItemsSoFar:N0}[/] items " + + $"([white]{state.ContentTypes.Count}[/] types)"; + }), + onPage: response => + { + ProcessPage(state, response); + state.PaginationToken = response.PaginationToken; + store.Save(StateFile, state, StateJsonContext.Default.ContentTypesState); + return Task.CompletedTask; + }, + ct: ct + ); state.Completed = true; state.PaginationToken = null; @@ -122,10 +113,13 @@ await AnsiConsole.Progress() /// private static void ExitIfUnregistered(ContentTypesState state) { - var unregistered = state.ContentTypes.Keys - .Where(uid => !PageContentTypes.All.Contains(uid) - && !PageContentTypes.Blocked.Contains(uid) - && !PageContentTypes.KnownNonPages.Contains(uid)) + var unregistered = state.ContentTypes + .Keys + .Where( + uid => + !PageContentTypes.All.Contains(uid) && !PageContentTypes.Blocked.Contains(uid) && + !PageContentTypes.KnownNonPages.Contains(uid) + ) .OrderBy(uid => uid, StringComparer.Ordinal) .ToList(); @@ -134,10 +128,12 @@ private static void ExitIfUnregistered(ContentTypesState state) AnsiConsole.WriteLine(); AnsiConsole.MarkupLine( - $"[red bold]{unregistered.Count} unregistered content type(s) found:[/] {Markup.Escape(string.Join(", ", unregistered))}"); + $"[red bold]{unregistered.Count} unregistered content type(s) found:[/] {Markup.Escape(string.Join(", ", unregistered))}" + ); AnsiConsole.MarkupLine( "[red]Add each to PageContentTypes.All (to sync it), PageContentTypes.Blocked (to ignore it for now), " + - "or PageContentTypes.KnownNonPages (if it's a component/taxonomy type, not a page).[/]"); + "or PageContentTypes.KnownNonPages (if it's a component/taxonomy type, not a page).[/]" + ); Environment.Exit(1); } @@ -168,9 +164,7 @@ private static void DisplayResults(ContentTypesState state) return; } - var groups = state.ContentTypes.Values - .OrderByDescending(e => e.Total) - .ToList(); + var groups = state.ContentTypes.Values.OrderByDescending(e => e.Total).ToList(); var table = new Table() .Border(TableBorder.Rounded) @@ -203,13 +197,14 @@ _ when PageContentTypes.All.Contains(info.Uid) => "[green]synced[/]", ? string.Join("\n", info.SampleUrls.Select(u => $"[dim]{Markup.Escape(u)}[/]")) : "[grey]—[/]"; - _ = table.AddRow( - new Markup(Markup.Escape(info.Uid)), - new Markup($"[white]{info.Total:N0}[/]"), - new Markup(hasUrlDisplay), - new Markup(statusDisplay), - new Markup(samples) - ); + _ = + table.AddRow( + new Markup(Markup.Escape(info.Uid)), + new Markup($"[white]{info.Total:N0}[/]"), + new Markup(hasUrlDisplay), + new Markup(statusDisplay), + new Markup(samples) + ); } AnsiConsole.Write(table); diff --git a/src/tooling/essc/Commands/DumpSamplesCommand.cs b/src/tooling/essc/Commands/DumpSamplesCommand.cs index f0828f2219..03f47d0cdc 100644 --- a/src/tooling/essc/Commands/DumpSamplesCommand.cs +++ b/src/tooling/essc/Commands/DumpSamplesCommand.cs @@ -8,9 +8,7 @@ namespace Elastic.SiteSearch.Cli.Commands; -internal sealed class DumpSamplesCommand( - ContentStackClient client -) +internal sealed class DumpSamplesCommand(ContentStackClient client) { private const string DefaultOutputDir = "/tmp/contentstack-samples"; @@ -23,10 +21,7 @@ ContentStackClient client /// /// Output directory for JSON files. /// Cancellation token. - public async Task Samples( - string? outputDir = null, - Cancel ct = default - ) + public async Task Samples(string? outputDir = null, Cancel ct = default) { var dir = outputDir ?? DefaultOutputDir; _ = Directory.CreateDirectory(dir); @@ -42,12 +37,7 @@ await AnsiConsole.Progress() .AutoRefresh(true) .AutoClear(false) .HideCompleted(false) - .Columns( - new SpinnerColumn(), - new TaskDescriptionColumn(), - new ProgressBarColumn(), - new PercentageColumn() - ) + .Columns(new SpinnerColumn(), new TaskDescriptionColumn(), new ProgressBarColumn(), new PercentageColumn()) .StartAsync(async ctx => { var task = ctx.AddTask("[aqua]Fetching samples[/]", maxValue: PageContentTypes.All.Length); @@ -59,11 +49,7 @@ await AnsiConsole.Progress() try { - var result = await client.InitialSyncAsync( - contentTypeUid: contentType, - maxPages: 1, - ct: ct - ); + var result = await client.InitialSyncAsync(contentTypeUid: contentType, maxPages: 1, ct: ct); if (result.Items.Count > 0 && result.Items[0].Data is { } data) { @@ -101,17 +87,9 @@ await AnsiConsole.Progress() foreach (var (contentType, itemCount, filePath) in results) { - var status = filePath != null - ? "[green]✓[/]" - : itemCount == 0 - ? "[grey]empty[/]" - : "[red]error[/]"; - - _ = table.AddRow( - new Markup(Markup.Escape(contentType)), - new Markup($"[white]{itemCount}[/]"), - new Markup(status) - ); + var status = filePath != null ? "[green]✓[/]" : itemCount == 0 ? "[grey]empty[/]" : "[red]error[/]"; + + _ = table.AddRow(new Markup(Markup.Escape(contentType)), new Markup($"[white]{itemCount}[/]"), new Markup(status)); } AnsiConsole.Write(table); diff --git a/src/tooling/essc/Commands/FindUrlCommand.cs b/src/tooling/essc/Commands/FindUrlCommand.cs index 7b2e9d6231..8271849ed6 100644 --- a/src/tooling/essc/Commands/FindUrlCommand.cs +++ b/src/tooling/essc/Commands/FindUrlCommand.cs @@ -39,10 +39,7 @@ private static string ToIndentedJson(JsonElement element) /// Path fragment to search for (substring match against the resolved path, so locale-prefixed variants like /pt/... still match). /// Restrict the scan to one Contentstack content type uid; omit to scan all types (slower — pages through full content). /// Cancellation token. - public async Task FindUrl( - [Argument] string pathPrefix, - string? contentType = null, - Cancel ct = default) + public async Task FindUrl([Argument] string pathPrefix, string? contentType = null, Cancel ct = default) { var contentTypes = contentType is not null ? [contentType] : PageContentTypes.All; @@ -61,45 +58,49 @@ public async Task FindUrl( await AnsiConsole.Status() .AutoRefresh(true) .Spinner(Spinner.Known.Dots) - .StartAsync("[aqua]Scanning...[/]", async statusCtx => - { - foreach (var type in contentTypes) + .StartAsync( + "[aqua]Scanning...[/]", + async statusCtx => { - var page = 0; - - _ = await client.InitialSyncAsync( - contentTypeUid: type, - onPage: page2 => - { - page++; - totalItems += page2.Items.Count; - - foreach (var item in page2.Items) - { - var doc = ContentStackMapper.ToSiteDocument(item); - if (doc is null) - continue; - if (!doc.Path.Contains(pathPrefix, StringComparison.OrdinalIgnoreCase)) - continue; - - matchedItems++; - var uid = item.Data?.TryGetProperty("uid", out var uidEl) == true - ? uidEl.GetString() ?? "?" - : "?"; - - var list = sightings.TryGetValue(doc.Path, out var existing) ? existing : []; - list.Add(new Sighting(type, uid, item.Type, item.EventAt, page, item.Data)); - sightings[doc.Path] = list; - } - - _ = statusCtx.Status( - $"[aqua]Scanning:[/] {Markup.Escape(type)} [dim](page {page}, {totalItems:N0} items seen, {matchedItems:N0} matched)[/]"); - return Task.CompletedTask; - }, - ct: ct - ); + foreach (var type in contentTypes) + { + var page = 0; + + _ = + await client.InitialSyncAsync( + contentTypeUid: type, + onPage: page2 => + { + page++; + totalItems += page2.Items.Count; + + foreach (var item in page2.Items) + { + var doc = ContentStackMapper.ToSiteDocument(item); + if (doc is null) + continue; + if (!doc.Path.Contains(pathPrefix, StringComparison.OrdinalIgnoreCase)) + continue; + + matchedItems++; + var uid = item.Data?.TryGetProperty("uid", out var uidEl) == true ? uidEl.GetString() ?? "?" : "?"; + + var list = sightings.TryGetValue(doc.Path, out var existing) ? existing : []; + list.Add(new Sighting(type, uid, item.Type, item.EventAt, page, item.Data)); + sightings[doc.Path] = list; + } + + _ = + statusCtx.Status( + $"[aqua]Scanning:[/] {Markup.Escape(type)} [dim](page {page}, {totalItems:N0} items seen, {matchedItems:N0} matched)[/]" + ); + return Task.CompletedTask; + }, + ct: ct + ); + } } - }); + ); AnsiConsole.WriteLine(); AnsiConsole.MarkupLine($"[dim]{totalItems:N0} items scanned, {matchedItems:N0} matched the path fragment.[/]"); @@ -116,15 +117,18 @@ await AnsiConsole.Status() foreach (var (path, list) in sightings.OrderBy(kvp => kvp.Key)) { var isDuplicate = list.Count > 1; - AnsiConsole.MarkupLine(isDuplicate - ? $"[red bold]DUPLICATE DELIVERY[/] [red]{Markup.Escape(path)}[/] [red]({list.Count} sightings in this pass)[/]" - : $"[aqua]{Markup.Escape(path)}[/] [dim](1 sighting)[/]"); + AnsiConsole.MarkupLine( + isDuplicate + ? $"[red bold]DUPLICATE DELIVERY[/] [red]{Markup.Escape(path)}[/] [red]({list.Count} sightings in this pass)[/]" + : $"[aqua]{Markup.Escape(path)}[/] [dim](1 sighting)[/]" + ); foreach (var s in list) { AnsiConsole.MarkupLine( $" [green]{Markup.Escape(s.ContentType)}[/] uid=[yellow]{Markup.Escape(s.Uid)}[/] " + - $"event={Markup.Escape(s.EventType)} eventAt=[dim]{Markup.Escape(s.EventAt ?? "?")}[/] page=[white]{s.Page}[/]"); + $"event={Markup.Escape(s.EventType)} eventAt=[dim]{Markup.Escape(s.EventAt ?? "?")}[/] page=[white]{s.Page}[/]" + ); } } @@ -146,15 +150,18 @@ await AnsiConsole.Status() continue; AnsiConsole.MarkupLine( - $" [dim]-- page={s.Page} eventAt={Markup.Escape(s.EventAt ?? "?")} event={Markup.Escape(s.EventType)} --[/]"); + $" [dim]-- page={s.Page} eventAt={Markup.Escape(s.EventAt ?? "?")} event={Markup.Escape(s.EventType)} --[/]" + ); AnsiConsole.WriteLine(ToIndentedJson(data)); } } AnsiConsole.WriteLine(); AnsiConsole.MarkupLine($"[aqua]{sightings.Count}[/] distinct paths matched."); - AnsiConsole.MarkupLine(duplicates.Count > 0 - ? $"[red bold]{duplicates.Count}[/] [red]path(s) were delivered more than once in this single pass — this is the 409 root cause.[/]" - : "[green]No duplicate deliveries observed in this pass.[/] [dim](run again — a duplicate may only surface intermittently, e.g. under a retried/slow request.)[/]"); + AnsiConsole.MarkupLine( + duplicates.Count > 0 + ? $"[red bold]{duplicates.Count}[/] [red]path(s) were delivered more than once in this single pass — this is the 409 root cause.[/]" + : "[green]No duplicate deliveries observed in this pass.[/] [dim](run again — a duplicate may only surface intermittently, e.g. under a retried/slow request.)[/]" + ); } } diff --git a/src/tooling/essc/Commands/IndicesCleanupPlanner.cs b/src/tooling/essc/Commands/IndicesCleanupPlanner.cs index bfbed1b594..d1ce8ca654 100644 --- a/src/tooling/essc/Commands/IndicesCleanupPlanner.cs +++ b/src/tooling/essc/Commands/IndicesCleanupPlanner.cs @@ -7,21 +7,10 @@ namespace Elastic.SiteSearch.Cli.Commands; /// Describes one alias-family to monitor and clean up. -internal sealed record AliasEntry( - string Source, - string Variant, - string Environment, - string LatestAlias, - string IndexPattern -); +internal sealed record AliasEntry(string Source, string Variant, string Environment, string LatestAlias, string IndexPattern); /// One concrete backing index and its cleanup disposition. -internal sealed record BackingIndex( - string Name, - DateTime Date, - bool IsActive, - AliasEntry Group -); +internal sealed record BackingIndex(string Name, DateTime Date, bool IsActive, AliasEntry Group); /// The computed cleanup plan for a single run. internal sealed record CleanupPlan( @@ -99,7 +88,8 @@ public static CleanupPlan Plan( IReadOnlyDictionary> indexAliases, IReadOnlyList knownAliases, int keep, - string? pageAlias = null) + string? pageAlias = null + ) { keep = Math.Max(1, keep); var knownLatestAliasSet = new HashSet(knownAliases.Select(a => a.LatestAlias), StringComparer.OrdinalIgnoreCase); @@ -111,9 +101,7 @@ public static CleanupPlan Plan( if (pageAlias is not null) { - var semanticLatestAlias = knownAliases - .FirstOrDefault(a => a.Source == "ws-catalog" && a.Variant == "semantic") - ?.LatestAlias; + var semanticLatestAlias = knownAliases.FirstOrDefault(a => a.Source == "ws-catalog" && a.Variant == "semantic")?.LatestAlias; if (semanticLatestAlias is not null) { string? pageTarget = null, semanticTarget = null; @@ -124,11 +112,15 @@ public static CleanupPlan Plan( if (aliases.Contains(semanticLatestAlias, StringComparer.OrdinalIgnoreCase)) semanticTarget = idx; } - if (pageTarget is not null && semanticTarget is not null && - !string.Equals(pageTarget, semanticTarget, StringComparison.OrdinalIgnoreCase)) + if ( + pageTarget is not null + && semanticTarget is not null + && !string.Equals(pageTarget, semanticTarget, StringComparison.OrdinalIgnoreCase) + ) { warnings.Add( - $"'{pageAlias}' → '{pageTarget}' differs from '{semanticLatestAlias}' → '{semanticTarget}'; both indices are protected"); + $"'{pageAlias}' → '{pageTarget}' differs from '{semanticLatestAlias}' → '{semanticTarget}'; both indices are protected" + ); } } } @@ -146,12 +138,21 @@ public static CleanupPlan Plan( var suffix = indexName[prefix.Length..]; // Well-known auxiliary indices (e.g. -ai-cache) are intentionally excluded from cleanup. - if (suffix.EndsWith("-ai-cache", StringComparison.OrdinalIgnoreCase) || - suffix.Equals("ai-cache", StringComparison.OrdinalIgnoreCase)) + if ( + suffix.EndsWith("-ai-cache", StringComparison.OrdinalIgnoreCase) || + suffix.Equals("ai-cache", StringComparison.OrdinalIgnoreCase) + ) continue; - if (!DateTime.TryParseExact(suffix, DateSuffix, System.Globalization.CultureInfo.InvariantCulture, - System.Globalization.DateTimeStyles.AssumeUniversal, out var date)) + if ( + !DateTime.TryParseExact( + suffix, + DateSuffix, + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeUniversal, + out var date + ) + ) { warnings.Add($"Skipping '{indexName}': suffix '{suffix}' did not parse as {DateSuffix}"); continue; @@ -180,7 +181,9 @@ public static CleanupPlan Plan( toDelete.AddRange(nonActive.Skip(keepNonActive)); if (active.Count > keep) - warnings.Add($"Active index count ({active.Count}) for {group.Key.Source}.{group.Key.Variant} exceeds --keep ({keep}); all active indices are retained."); + warnings.Add( + $"Active index count ({active.Count}) for {group.Key.Source}.{group.Key.Variant} exceeds --keep ({keep}); all active indices are retained." + ); } return new CleanupPlan(toKeep.AsReadOnly(), toDelete.AsReadOnly(), warnings.AsReadOnly()); diff --git a/src/tooling/essc/Commands/IndicesCommands.cs b/src/tooling/essc/Commands/IndicesCommands.cs index 5e40809dcc..f97c53dd6b 100644 --- a/src/tooling/essc/Commands/IndicesCommands.cs +++ b/src/tooling/essc/Commands/IndicesCommands.cs @@ -106,8 +106,7 @@ internal sealed class IndicesCleanupOptions internal sealed class IndicesCommands(SourcingConfiguration config, ILoggerFactory loggerFactory) { private static bool IsInteractive() => - string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) && - AnsiConsole.Profile.Capabilities.Interactive; + string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) && AnsiConsole.Profile.Capabilities.Interactive; /// /// Incrementally reindex docs-assembler.*, site-*, and labs-* semantic @@ -171,8 +170,7 @@ public async Task Unify( var batchTsStr = batchTs.ToString("o"); float? rpsOpt = rps < 0 ? null : rps; - var extraLabels = (aliases ?? "") - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var extraLabels = (aliases ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var docsAssemblerAlias = $"docs-assembler.semantic-{env}-latest"; var siteAlias = SiteMappingContext.SiteDocumentSemantic.CreateContext(type: buildType, env: env).ResolveWriteAlias(); @@ -185,7 +183,9 @@ public async Task Unify( AnsiConsole.MarkupLine($"[dim]Environment:[/] [white]{Markup.Escape(env)}[/]"); AnsiConsole.MarkupLine($"[dim]Sources:[/] [white]{Markup.Escape(string.Join(", ", sourceAliases))}[/]"); AnsiConsole.MarkupLine($"[dim]Alias:[/] [white]{Markup.Escape(pageAlias)}[/]"); - AnsiConsole.MarkupLine($"[dim]Slices:[/] [white]{Markup.Escape(slices)}[/] [dim]rps:[/] [white]{(rps < 0 ? "unlimited" : rps.ToString("F0"))}[/]"); + AnsiConsole.MarkupLine( + $"[dim]Slices:[/] [white]{Markup.Escape(slices)}[/] [dim]rps:[/] [white]{(rps < 0 ? "unlimited" : rps.ToString("F0"))}[/]" + ); if (extraLabels.Length > 0) AnsiConsole.MarkupLine($"[dim]Aliases:[/] [white]{Markup.Escape(string.Join(", ", extraLabels))}[/]"); AnsiConsole.WriteLine(); @@ -196,13 +196,11 @@ public async Task Unify( var synonymSetName = $"docs-assembler-{env}"; var indexTimeSynonyms = IndexTimeSynonyms.Docs; - var lexicalContext = WebsiteSearchMappingContext.WebsiteSearchDocument - .CreateContext(env: env) with + var lexicalContext = WebsiteSearchMappingContext.WebsiteSearchDocument.CreateContext(env: env) with { ConfigureAnalysis = a => SharedAnalysisFactory.BuildAnalysis(a, synonymSetName, indexTimeSynonyms) }; - var semanticContext = WebsiteSearchMappingContext.WebsiteSearchDocumentSemantic - .CreateContext(env: env) with + var semanticContext = WebsiteSearchMappingContext.WebsiteSearchDocumentSemantic.CreateContext(env: env) with { ConfigureAnalysis = a => SharedAnalysisFactory.BuildAnalysis(a, synonymSetName, indexTimeSynonyms) }; @@ -216,15 +214,20 @@ public async Task Unify( OnRolloverDecision = info => { var roll = info.RolledOver ? "new backing index" : "reuse"; - logger.LogInformation("[{Label}] rollover={Roll} local={Local} remote={Remote}", - info.Label, roll, info.LocalHash, info.RemoteHash); + logger.LogInformation( + "[{Label}] rollover={Roll} local={Local} remote={Remote}", + info.Label, + roll, + info.LocalHash, + info.RemoteHash + ); }, }; var context = await orchestrator.StartAsync(BootstrapMethod.Silent, ct); - var lexicalAlias = context.PrimaryWriteAlias; // website-search.lexical-{env}-latest - var semanticAlias = context.SecondaryWriteAlias; // website-search.semantic-{env}-latest + var lexicalAlias = context.PrimaryWriteAlias; // website-search.lexical-{env}-latest + var semanticAlias = context.SecondaryWriteAlias; // website-search.semantic-{env}-latest // ── Source hash check ───────────────────────────────────────────────────── var (currentSourceHash, perSourceHashes) = await FetchCombinedSourceHashAsync(transport, sourceAliases, ct); @@ -237,7 +240,8 @@ public async Task Unify( AnsiConsole.MarkupLine( sourceHashChanged ? $"[dim] combined: [yellow]{Markup.Escape(currentSourceHash)}[/] (stored: [yellow]{Markup.Escape(storedSourceHash ?? "none")}[/]) — [yellow]changed[/][/]" - : $"[dim] combined: [white]{Markup.Escape(currentSourceHash)}[/] (stored: [white]{Markup.Escape(storedSourceHash ?? "none")}[/]) — unchanged[/]"); + : $"[dim] combined: [white]{Markup.Escape(currentSourceHash)}[/] (stored: [white]{Markup.Escape(storedSourceHash ?? "none")}[/]) — unchanged[/]" + ); AnsiConsole.WriteLine(); // ── Resolve backing indices ─────────────────────────────────────────────── @@ -259,10 +263,12 @@ public async Task Unify( } else { - lexicalIndex = await ResolveAliasIndexAsync(transport, lexicalAlias, ct) - ?? throw new InvalidOperationException($"Lexical alias '{lexicalAlias}' not found."); - semanticIndex = await ResolveAliasIndexAsync(transport, semanticAlias, ct) - ?? throw new InvalidOperationException($"Semantic alias '{semanticAlias}' not found."); + lexicalIndex = + await ResolveAliasIndexAsync(transport, lexicalAlias, ct) ?? + throw new InvalidOperationException($"Lexical alias '{lexicalAlias}' not found."); + semanticIndex = + await ResolveAliasIndexAsync(transport, semanticAlias, ct) ?? + throw new InvalidOperationException($"Semantic alias '{semanticAlias}' not found."); } // Apply rename after the orchestrator has set up the canonical template and backing indices. @@ -278,7 +284,9 @@ public async Task Unify( var isFullReindex = orchestrator.Strategy == IngestSyncStrategy.Multiplex || sourceHashChanged; var currentPageAlias = await ResolveAliasIndexAsync(transport, pageAlias, ct); - AnsiConsole.MarkupLine($"[green]✓[/] Strategy: [white]{orchestrator.Strategy}[/]{(sourceHashChanged ? " [yellow]+ source hash rollover[/]" : "")}"); + AnsiConsole.MarkupLine( + $"[green]✓[/] Strategy: [white]{orchestrator.Strategy}[/]{(sourceHashChanged ? " [yellow]+ source hash rollover[/]" : "")}" + ); AnsiConsole.MarkupLine($"[dim]Mode:[/] [white]{(isFullReindex ? "full reindex" : "incremental")}[/]"); AnsiConsole.WriteLine(); AnsiConsole.MarkupLine("[dim]Indices:[/]"); @@ -292,8 +300,12 @@ public async Task Unify( AnsiConsole.MarkupLine($"[dim] semantic: [white]{semanticDisplay}[/][/]"); AnsiConsole.WriteLine(); AnsiConsole.MarkupLine("[dim]Current aliases:[/]"); - AnsiConsole.MarkupLine($"[dim] {Markup.Escape(lexicalAlias)} → [white]{Markup.Escape(await ResolveAliasIndexAsync(transport, lexicalAlias, ct) ?? "not set")}[/][/]"); - AnsiConsole.MarkupLine($"[dim] {Markup.Escape(semanticAlias)} → [white]{Markup.Escape(await ResolveAliasIndexAsync(transport, semanticAlias, ct) ?? "not set")}[/][/]"); + AnsiConsole.MarkupLine( + $"[dim] {Markup.Escape(lexicalAlias)} → [white]{Markup.Escape(await ResolveAliasIndexAsync(transport, lexicalAlias, ct) ?? "not set")}[/][/]" + ); + AnsiConsole.MarkupLine( + $"[dim] {Markup.Escape(semanticAlias)} → [white]{Markup.Escape(await ResolveAliasIndexAsync(transport, semanticAlias, ct) ?? "not set")}[/][/]" + ); AnsiConsole.MarkupLine($"[dim] {Markup.Escape(pageAlias)} → [white]{Markup.Escape(currentPageAlias ?? "not set")}[/][/]"); AnsiConsole.WriteLine(); @@ -302,9 +314,23 @@ public async Task Unify( const string semanticSlices = "1"; var state = new UnifyRunState( - sourceAliases, lexicalIndex, semanticIndex, originalSemanticIndex, semanticWasRenamed, - lexicalAlias, semanticAlias, pageAlias, extraLabels, env, isFullReindex, batchTsStr, - currentSourceHash, semanticSlices, slices, rpsOpt); + sourceAliases, + lexicalIndex, + semanticIndex, + originalSemanticIndex, + semanticWasRenamed, + lexicalAlias, + semanticAlias, + pageAlias, + extraLabels, + env, + isFullReindex, + batchTsStr, + currentSourceHash, + semanticSlices, + slices, + rpsOpt + ); try { @@ -347,7 +373,8 @@ private sealed record UnifyRunState( string CurrentSourceHash, string SemanticSlices, string Slices, - float? RpsOpt); + float? RpsOpt + ); private async Task RunUnifySteps(DistributedTransport transport, UnifyRunState state, ILogger logger, Cancel ct) { @@ -356,9 +383,14 @@ private async Task RunUnifySteps(DistributedTransport transport, UnifyRunState s foreach (var source in state.SourceAliases) { var fillBody = BuildLexicalFillBody(source, state.LexicalIndex, state.BatchTsStr); - if (!await RunServerReindexAsync(transport, - new ServerReindexOptions { Body = fillBody, Slices = state.Slices, RequestsPerSecond = state.RpsOpt }, - $"fill-lexical ({Markup.Escape(source)})", ct)) + if ( + !await RunServerReindexAsync( + transport, + new ServerReindexOptions { Body = fillBody, Slices = state.Slices, RequestsPerSecond = state.RpsOpt }, + $"fill-lexical ({Markup.Escape(source)})", + ct + ) + ) { AnsiConsole.MarkupLine("[red]Aborting — lexical fill failed.[/]"); Environment.Exit(1); @@ -387,9 +419,20 @@ private async Task RunUnifySteps(DistributedTransport transport, UnifyRunState s { // ── Full reindex to semantic (all docs trigger inference) ──────────── // slices=1: prevents es_rejected_execution_exception from concurrent inference bulk. - if (!await RunServerReindexAsync(transport, - new ServerReindexOptions { Source = state.LexicalIndex, Destination = state.SemanticIndex, Slices = state.SemanticSlices, RequestsPerSecond = state.RpsOpt }, - "full-semantic", ct)) + if ( + !await RunServerReindexAsync( + transport, + new ServerReindexOptions + { + Source = state.LexicalIndex, + Destination = state.SemanticIndex, + Slices = state.SemanticSlices, + RequestsPerSecond = state.RpsOpt + }, + "full-semantic", + ct + ) + ) { // Reindex failed mid-way. The semantic backing index is partial — delete it so // the next run starts clean (Multiplex will recreate it). @@ -408,6 +451,7 @@ private async Task RunUnifySteps(DistributedTransport transport, UnifyRunState s _ = await PruneOldIndicesAsync(transport, $"ws-catalog.semantic-{state.Env}-*", keepCount: 3, logger, ct); } else // Reindex mode — incremental + { // Global cutoff: max(last_updated) across all docs currently in semantic. // Changed/new source docs have newer last_updated from their sync → caught by inference step. @@ -418,13 +462,22 @@ private async Task RunUnifySteps(DistributedTransport transport, UnifyRunState s // ── Inference step ─────────────────────────────────────────────────── // Reindex only docs whose last_updated > cutoff from lexical → semantic (slices=1, inference). // These are docs that changed in any source since the last unify run. - var inferenceBody = - "{\"source\":{\"index\":\"" + state.LexicalIndex + - "\",\"query\":{\"range\":{\"last_updated\":{\"gt\":\"" + cutoff.ToString("o") + "\"}}}}," + - "\"dest\":{\"index\":\"" + state.SemanticIndex + "\"}}"; - if (!await RunServerReindexAsync(transport, - new ServerReindexOptions { Body = inferenceBody, Slices = state.SemanticSlices, RequestsPerSecond = state.RpsOpt }, - "inference-reindex", ct)) + var inferenceBody = "{\"source\":{\"index\":\"" + + state.LexicalIndex + + "\",\"query\":{\"range\":{\"last_updated\":{\"gt\":\"" + + cutoff.ToString("o") + + "\"}}}}," + + "\"dest\":{\"index\":\"" + + state.SemanticIndex + + "\"}}"; + if ( + !await RunServerReindexAsync( + transport, + new ServerReindexOptions { Body = inferenceBody, Slices = state.SemanticSlices, RequestsPerSecond = state.RpsOpt }, + "inference-reindex", + ct + ) + ) { AnsiConsole.MarkupLine("[red]Aborting — inference reindex failed.[/]"); Environment.Exit(1); @@ -435,9 +488,14 @@ private async Task RunUnifySteps(DistributedTransport transport, UnifyRunState s // Reindex stale lexical docs (batch_index_date < batchTimestamp = not in current fill) // to semantic using a delete script. This removes docs deleted from all sources. var deleteBody = BuildDeleteScriptBody(state.LexicalIndex, state.SemanticIndex, state.BatchTsStr); - if (!await RunServerReindexAsync(transport, - new ServerReindexOptions { Body = deleteBody, Slices = state.Slices }, - "reindex-deletes", ct)) + if ( + !await RunServerReindexAsync( + transport, + new ServerReindexOptions { Body = deleteBody, Slices = state.Slices }, + "reindex-deletes", + ct + ) + ) { AnsiConsole.MarkupLine("[red]Aborting — delete step failed.[/]"); Environment.Exit(1); @@ -454,7 +512,9 @@ private async Task RunUnifySteps(DistributedTransport transport, UnifyRunState s // Persist combined source hash into component template for next-run rollover detection. await WriteStoredSourceHashAsync(transport, state.Env, state.CurrentSourceHash, ct); - AnsiConsole.MarkupLine($"[dim]Stored source hash {Markup.Escape(state.CurrentSourceHash)} → {Markup.Escape(UnifyStateTemplateName(state.Env))}[/]"); + AnsiConsole.MarkupLine( + $"[dim]Stored source hash {Markup.Escape(state.CurrentSourceHash)} → {Markup.Escape(UnifyStateTemplateName(state.Env))}[/]" + ); foreach (var label in state.ExtraLabels) { @@ -472,22 +532,27 @@ private async Task RunUnifySteps(DistributedTransport transport, UnifyRunState s private static string ResolveTemplateName(string writeAlias) => (writeAlias.EndsWith("-latest", StringComparison.Ordinal) - ? writeAlias[..^7] // strip "-latest" - : writeAlias) - + "-template"; + ? writeAlias[..^7] // strip "-latest" + + : writeAlias) + "-template"; - private static async Task<(string Combined, IReadOnlyList<(string Alias, string Template, string Hash)> PerSource)> - FetchCombinedSourceHashAsync(DistributedTransport transport, IEnumerable sourceAliases, CancellationToken ct) + private static async Task<(string Combined, IReadOnlyList<(string Alias, string Template, string Hash)> PerSource)> FetchCombinedSourceHashAsync( + DistributedTransport transport, + IEnumerable sourceAliases, + CancellationToken ct + ) { var perSource = new List<(string, string, string)>(); var parts = new List(); foreach (var alias in sourceAliases) { var template = ResolveTemplateName(alias); - var resp = await transport.RequestAsync( - Transport.HttpMethod.GET, - $"_index_template/{Uri.EscapeDataString(template)}?filter_path=index_templates.index_template._meta.hash", - cancellationToken: ct); + var resp = + await transport.RequestAsync( + Transport.HttpMethod.GET, + $"_index_template/{Uri.EscapeDataString(template)}?filter_path=index_templates.index_template._meta.hash", + cancellationToken: ct + ); var hash = resp.Get("index_templates.0.index_template._meta.hash") ?? "missing"; perSource.Add((alias, template, hash)); parts.Add(alias + "=" + hash); @@ -495,35 +560,42 @@ private static string ResolveTemplateName(string writeAlias) => return (HashedBulkUpdate.CreateHash(parts.ToArray()), perSource); } - private static string UnifyStateTemplateName(string env) => - $"website-search-unify-state-{env}"; + private static string UnifyStateTemplateName(string env) => $"website-search-unify-state-{env}"; - private static async Task ReadStoredSourceHashAsync( - DistributedTransport transport, string env, CancellationToken ct) + private static async Task ReadStoredSourceHashAsync(DistributedTransport transport, string env, CancellationToken ct) { var name = UnifyStateTemplateName(env); - var resp = await transport.RequestAsync( - Transport.HttpMethod.GET, - $"_component_template/{Uri.EscapeDataString(name)}", - cancellationToken: ct); + var resp = + await transport.RequestAsync( + Transport.HttpMethod.GET, + $"_component_template/{Uri.EscapeDataString(name)}", + cancellationToken: ct + ); if (!resp.ApiCallDetails.HasSuccessfulStatusCode) return null; return resp.Get("component_templates.0.component_template._meta.source_hash"); } private static async Task WriteStoredSourceHashAsync( - DistributedTransport transport, string env, string combinedHash, CancellationToken ct) + DistributedTransport transport, + string env, + string combinedHash, + CancellationToken ct + ) { var name = UnifyStateTemplateName(env); var body = "{\"_meta\":{\"source_hash\":\"" + combinedHash + "\"},\"template\":{}}"; - _ = await transport.PutAsync( - $"_component_template/{Uri.EscapeDataString(name)}", - PostData.String(body), ct); + _ = await transport.PutAsync($"_component_template/{Uri.EscapeDataString(name)}", PostData.String(body), ct); } // ─── ServerReindex helpers ────────────────────────────────────────────────── - private async Task RunServerReindexAsync(DistributedTransport transport, ServerReindexOptions opts, string label, CancellationToken ct) + private async Task RunServerReindexAsync( + DistributedTransport transport, + ServerReindexOptions opts, + string label, + CancellationToken ct + ) { AnsiConsole.MarkupLine($"[aqua]{label}[/]..."); var reindex = new ServerReindex(transport, opts); @@ -551,7 +623,9 @@ await AnsiConsole.Progress() await foreach (var p in reindex.MonitorAsync(ct)) { last = p; - AnsiConsole.MarkupLine($"[dim]{label}[/] {(p.FractionComplete.HasValue ? $"{p.FractionComplete:P0}" : "?")} — created={p.Created:N0} updated={p.Updated:N0} total={p.Total:N0}"); + AnsiConsole.MarkupLine( + $"[dim]{label}[/] {(p.FractionComplete.HasValue ? $"{p.FractionComplete:P0}" : "?")} — created={p.Created:N0} updated={p.Updated:N0} total={p.Total:N0}" + ); } } @@ -561,16 +635,14 @@ await AnsiConsole.Progress() if (last?.Failures.Count > 0) { AnsiConsole.MarkupLine($"[yellow]⚠ {label} — {last.Failures.Count:N0} document(s) failed[/]"); - foreach (var group in last.Failures - .GroupBy(f => (f.CauseType, f.CauseReason)) - .OrderByDescending(g => g.Count()) - .Take(10)) + foreach (var group in last.Failures.GroupBy(f => (f.CauseType, f.CauseReason)).OrderByDescending(g => g.Count()).Take(10)) { var sample = group.First(); AnsiConsole.MarkupLine( $"[dim] ×{group.Count():N0}[/] [red]{Markup.Escape(sample.CauseType ?? "unknown")}[/]: " + - $"{Markup.Escape(sample.CauseReason ?? "no reason given")} " + - $"[dim](e.g. {Markup.Escape(sample.Index ?? "?")}/{Markup.Escape(sample.Id ?? "?")})[/]"); + $"{Markup.Escape(sample.CauseReason ?? "no reason given")} " + + $"[dim](e.g. {Markup.Escape(sample.Index ?? "?")}/{Markup.Escape(sample.Id ?? "?")})[/]" + ); } if (last.Failures.Count > 10) AnsiConsole.MarkupLine($"[dim] ... {last.Failures.Count - 10:N0} more failure(s) not shown[/]"); @@ -589,15 +661,16 @@ await AnsiConsole.Progress() } private async Task RunDeleteByQueryAsync( - DistributedTransport transport, string index, string query, string slices, string label, CancellationToken ct) + DistributedTransport transport, + string index, + string query, + string slices, + string label, + CancellationToken ct + ) { AnsiConsole.MarkupLine($"[aqua]{label}[/]..."); - var dbq = new DeleteByQuery(transport, new DeleteByQueryOptions - { - Index = index, - QueryBody = query, - Slices = slices, - }); + var dbq = new DeleteByQuery(transport, new DeleteByQueryOptions { Index = index, QueryBody = query, Slices = slices, }); long deleted = 0; await foreach (var p in dbq.MonitorAsync(ct)) { @@ -611,43 +684,65 @@ private async Task RunDeleteByQueryAsync( // ─── Reindex body builders ────────────────────────────────────────────────── private static string BuildLexicalFillBody(string sourceAlias, string lexicalIndex, string batchTs) => - "{\"source\":{\"index\":\"" + sourceAlias + "\"}," + - "\"dest\":{\"index\":\"" + lexicalIndex + "\"}," + - "\"script\":{\"source\":\"ctx._source.batch_index_date = params.ts;\"," + - "\"params\":{\"ts\":\"" + batchTs + "\"}}}"; + "{\"source\":{\"index\":\"" + + sourceAlias + + "\"}," + + "\"dest\":{\"index\":\"" + + lexicalIndex + + "\"}," + + "\"script\":{\"source\":\"ctx._source.batch_index_date = params.ts;\"," + + "\"params\":{\"ts\":\"" + + batchTs + + "\"}}}"; // Reindex stale lexical docs (batch_index_date < batchTs = not in current fill = deleted from source) // to semantic using a delete script. Mirrors orchestrator's ReindexWithDeleteScriptAsync. private static string BuildDeleteScriptBody(string lexicalIndex, string semanticIndex, string batchTs) => - "{\"source\":{\"index\":\"" + lexicalIndex + - "\",\"query\":{\"range\":{\"batch_index_date\":{\"lt\":\"" + batchTs + "\"}}}}," + - "\"dest\":{\"index\":\"" + semanticIndex + "\"}," + - "\"script\":{\"source\":\"ctx.op = 'delete'\",\"lang\":\"painless\"}}"; + "{\"source\":{\"index\":\"" + + lexicalIndex + + "\",\"query\":{\"range\":{\"batch_index_date\":{\"lt\":\"" + + batchTs + + "\"}}}}," + + "\"dest\":{\"index\":\"" + + semanticIndex + + "\"}," + + "\"script\":{\"source\":\"ctx.op = 'delete'\",\"lang\":\"painless\"}}"; // ─── Cutoff query ─────────────────────────────────────────────────────────── private static async Task QueryMaxLastUpdatedAsync( - DistributedTransport transport, string semanticIndex, CancellationToken ct) + DistributedTransport transport, + string semanticIndex, + CancellationToken ct + ) { - var body = /*lang=json,strict*/ "{\"size\":0,\"aggs\":{\"max_lu\":{\"max\":{\"field\":\"last_updated\"}}}}"; - var resp = await transport.RequestAsync( - Transport.HttpMethod.POST, $"{semanticIndex}/_search", PostData.String(body), cancellationToken: ct); + var body = /*lang=json,strict*/ "{\"size\":0,\"aggs\":{\"max_lu\":{\"max\":{\"field\":\"last_updated\"}}}}"; + var resp = + await transport.RequestAsync( + Transport.HttpMethod.POST, + $"{semanticIndex}/_search", + PostData.String(body), + cancellationToken: ct + ); return resp.Get("aggregations.max_lu.value_as_string") ?? DateTimeOffset.MinValue; } // ─── Alias helpers ────────────────────────────────────────────────────────── - private static async Task ResolveAliasIndexAsync( - DistributedTransport transport, string alias, CancellationToken ct) + private static async Task ResolveAliasIndexAsync(DistributedTransport transport, string alias, CancellationToken ct) { // _cat/aliases?h=index returns a plain-text line containing just the index name. // Using text/plain avoids JSON array parsing (JsonResponse.Get doesn't traverse arrays). // This mirrors IncrementalSyncOrchestrator.ResolveExistingIndexAsync internally. var rq = new RequestConfiguration { Accept = "text/plain" }; - var resp = await transport.RequestAsync( - Transport.HttpMethod.GET, - $"_cat/aliases/{Uri.EscapeDataString(alias)}?h=index", - null, rq, ct); + var resp = + await transport.RequestAsync( + Transport.HttpMethod.GET, + $"_cat/aliases/{Uri.EscapeDataString(alias)}?h=index", + null, + rq, + ct + ); var index = resp.Body?.Trim('\n', '\r', ' '); return string.IsNullOrEmpty(index) ? null : index; } @@ -658,25 +753,42 @@ private static async Task QueryMaxLastUpdatedAsync( /// and their inference configuration exist before the inference reindex runs. /// private static async Task BootstrapSemanticIndexAsync( - DistributedTransport fromTransport, DistributedTransport toTransport, - string sourceAlias, string destIndex, ILogger logger, CancellationToken ct) + DistributedTransport fromTransport, + DistributedTransport toTransport, + string sourceAlias, + string destIndex, + ILogger logger, + CancellationToken ct + ) { - AnsiConsole.MarkupLine($"[dim] Bootstrapping [white]{Markup.Escape(destIndex)}[/] from source mapping of [white]{Markup.Escape(sourceAlias)}[/]...[/]"); + AnsiConsole.MarkupLine( + $"[dim] Bootstrapping [white]{Markup.Escape(destIndex)}[/] from source mapping of [white]{Markup.Escape(sourceAlias)}[/]...[/]" + ); - var mappingResp = await fromTransport.RequestAsync( - Transport.HttpMethod.GET, $"{Uri.EscapeDataString(sourceAlias)}/_mapping", cancellationToken: ct); + var mappingResp = + await fromTransport.RequestAsync( + Transport.HttpMethod.GET, + $"{Uri.EscapeDataString(sourceAlias)}/_mapping", + cancellationToken: ct + ); if (!mappingResp.ApiCallDetails.HasSuccessfulStatusCode || mappingResp.Body is null) { - logger.LogError("Failed to fetch source mapping from {Alias}: {Info}", sourceAlias, mappingResp.ApiCallDetails.DebugInformation); + logger.LogError( + "Failed to fetch source mapping from {Alias}: {Info}", + sourceAlias, + mappingResp.ApiCallDetails.DebugInformation + ); return; } // Fetch only the analysis settings — tokenizers, analyzers, filters that the mapping references. // filter_path trims the response to just the analysis sub-tree. - var settingsResp = await fromTransport.RequestAsync( - Transport.HttpMethod.GET, - $"{Uri.EscapeDataString(sourceAlias)}/_settings?filter_path=**.index.analysis", - cancellationToken: ct); + var settingsResp = + await fromTransport.RequestAsync( + Transport.HttpMethod.GET, + $"{Uri.EscapeDataString(sourceAlias)}/_settings?filter_path=**.index.analysis", + cancellationToken: ct + ); // Response shapes: // _mapping: { "": { "mappings": { ... } } } @@ -700,8 +812,13 @@ private static async Task BootstrapSemanticIndexAsync( ? $"{{\"settings\":{{\"analysis\":{analysis.ToJsonString()}}},\"mappings\":{mappings.ToJsonString()}}}" : $"{{\"mappings\":{mappings.ToJsonString()}}}"; - var createResp = await toTransport.RequestAsync( - Transport.HttpMethod.PUT, Uri.EscapeDataString(destIndex), PostData.String(body), cancellationToken: ct); + var createResp = + await toTransport.RequestAsync( + Transport.HttpMethod.PUT, + Uri.EscapeDataString(destIndex), + PostData.String(body), + cancellationToken: ct + ); if (!createResp.ApiCallDetails.HasSuccessfulStatusCode) logger.LogError("Failed to create {Index} with source mapping: {Info}", destIndex, createResp.ApiCallDetails.DebugInformation); else @@ -712,7 +829,14 @@ private static async Task BootstrapSemanticIndexAsync( // fresh destination index — these are cluster/identity-specific and must not be replayed. private static readonly string[] ReadOnlySettingsKeys = [ - "creation_date", "creation_date_string", "uuid", "version", "provided_name", "resize", "routing", "history", + "creation_date", + "creation_date_string", + "uuid", + "version", + "provided_name", + "resize", + "routing", + "history", ]; /// @@ -721,21 +845,40 @@ private static async Task BootstrapSemanticIndexAsync( /// to faithfully recreate a destination index before reindexing into it. /// private static async Task CopyIndexDefinitionAsync( - DistributedTransport fromTransport, DistributedTransport toTransport, - string sourceIndex, string destIndex, ILogger logger, CancellationToken ct) + DistributedTransport fromTransport, + DistributedTransport toTransport, + string sourceIndex, + string destIndex, + ILogger logger, + CancellationToken ct + ) { - AnsiConsole.MarkupLine($"[dim] Creating [white]{Markup.Escape(destIndex)}[/] from source mapping+settings of [white]{Markup.Escape(sourceIndex)}[/]...[/]"); + AnsiConsole.MarkupLine( + $"[dim] Creating [white]{Markup.Escape(destIndex)}[/] from source mapping+settings of [white]{Markup.Escape(sourceIndex)}[/]...[/]" + ); - var mappingResp = await fromTransport.RequestAsync( - Transport.HttpMethod.GET, $"{Uri.EscapeDataString(sourceIndex)}/_mapping", cancellationToken: ct); + var mappingResp = + await fromTransport.RequestAsync( + Transport.HttpMethod.GET, + $"{Uri.EscapeDataString(sourceIndex)}/_mapping", + cancellationToken: ct + ); if (!mappingResp.ApiCallDetails.HasSuccessfulStatusCode || mappingResp.Body is null) { - logger.LogError("Failed to fetch source mapping from {Index}: {Info}", sourceIndex, mappingResp.ApiCallDetails.DebugInformation); + logger.LogError( + "Failed to fetch source mapping from {Index}: {Info}", + sourceIndex, + mappingResp.ApiCallDetails.DebugInformation + ); return; } - var settingsResp = await fromTransport.RequestAsync( - Transport.HttpMethod.GET, $"{Uri.EscapeDataString(sourceIndex)}/_settings", cancellationToken: ct); + var settingsResp = + await fromTransport.RequestAsync( + Transport.HttpMethod.GET, + $"{Uri.EscapeDataString(sourceIndex)}/_settings", + cancellationToken: ct + ); // Response shapes: // _mapping: { "": { "mappings": { ... } } } @@ -764,10 +907,19 @@ private static async Task CopyIndexDefinitionAsync( ? $"{{\"settings\":{{\"index\":{settings.ToJsonString()}}},\"mappings\":{mappings.ToJsonString()}}}" : $"{{\"mappings\":{mappings.ToJsonString()}}}"; - var createResp = await toTransport.RequestAsync( - Transport.HttpMethod.PUT, Uri.EscapeDataString(destIndex), PostData.String(body), cancellationToken: ct); + var createResp = + await toTransport.RequestAsync( + Transport.HttpMethod.PUT, + Uri.EscapeDataString(destIndex), + PostData.String(body), + cancellationToken: ct + ); if (!createResp.ApiCallDetails.HasSuccessfulStatusCode) - logger.LogError("Failed to create {Index} with source mapping+settings: {Info}", destIndex, createResp.ApiCallDetails.DebugInformation); + logger.LogError( + "Failed to create {Index} with source mapping+settings: {Info}", + destIndex, + createResp.ApiCallDetails.DebugInformation + ); else AnsiConsole.MarkupLine($"[green]✓[/] Created [white]{Markup.Escape(destIndex)}[/] with source mapping+settings"); } @@ -779,9 +931,7 @@ private static async Task CopyIndexDefinitionAsync( /// argument is null or empty. /// private static string ApplyRename(string indexName, string? from, string? to) => - !string.IsNullOrEmpty(from) && !string.IsNullOrEmpty(to) - ? Regex.Replace(indexName, from, to) - : indexName; + !string.IsNullOrEmpty(from) && !string.IsNullOrEmpty(to) ? Regex.Replace(indexName, from, to) : indexName; /// /// Derives a date-stamped backing index name from an alias on first run. @@ -795,7 +945,12 @@ private static string DeriveBackingIndexName(string alias, DateTimeOffset ts) } private static async Task PointAliasAsync( - DistributedTransport transport, string alias, string destIndex, ILogger logger, CancellationToken ct) + DistributedTransport transport, + string alias, + string destIndex, + ILogger logger, + CancellationToken ct + ) { var current = await ResolveAliasIndexAsync(transport, alias, ct); var sb = new StringBuilder("{\"actions\":["); @@ -809,13 +964,20 @@ private static async Task PointAliasAsync( } private static async Task> PruneOldIndicesAsync( - DistributedTransport transport, string pattern, int keepCount, ILogger logger, CancellationToken ct) + DistributedTransport transport, + string pattern, + int keepCount, + ILogger logger, + CancellationToken ct + ) { var pruned = new List(); - var resp = await transport.RequestAsync( - Transport.HttpMethod.GET, - $"_cat/indices/{Uri.EscapeDataString(pattern)}?h=index&s=creation.date.string:desc&format=json", - cancellationToken: ct); + var resp = + await transport.RequestAsync( + Transport.HttpMethod.GET, + $"_cat/indices/{Uri.EscapeDataString(pattern)}?h=index&s=creation.date.string:desc&format=json", + cancellationToken: ct + ); if (!resp.ApiCallDetails.HasSuccessfulStatusCode) return pruned; @@ -838,14 +1000,20 @@ private static async Task> PruneOldIndicesAsync( } if (pruned.Count > 0) - AnsiConsole.MarkupLine($"[dim]Pruned {pruned.Count} old backing {(pruned.Count == 1 ? "index" : "indices")}:[/] {string.Join(", ", pruned.Select(Markup.Escape))}"); + AnsiConsole.MarkupLine( + $"[dim]Pruned {pruned.Count} old backing {(pruned.Count == 1 ? "index" : "indices")}:[/] {string.Join(", ", pruned.Select(Markup.Escape))}" + ); return pruned; } // ─── Channel options ──────────────────────────────────────────────────────── private void ConfigureChannelOptions( - string label, IngestChannelOptions options, ElasticsearchEndpoint endpoint, bool semantic = false) + string label, + IngestChannelOptions options, + ElasticsearchEndpoint endpoint, + bool semantic = false + ) { var log = loggerFactory.CreateLogger(); options.BufferOptions = new BufferOptions @@ -892,7 +1060,8 @@ public async Task SyncRemote( [Argument] string alias, [AsParameters] IndicesRemoteSyncOptions options, bool stripSemanticFields = false, - Cancel ct = default) + Cancel ct = default + ) { var (fromEndpoint, transport, toUri) = ResolveSyncTransport(options); var slicesStr = options.Slices.ToString(); @@ -902,7 +1071,9 @@ public async Task SyncRemote( var head = await transport.HeadAsync(alias, ct); if (head.ApiCallDetails.HttpStatusCode != 200) { - AnsiConsole.MarkupLine($"[red]✗ Destination alias/index [white]{Markup.Escape(alias)}[/] does not exist on {Markup.Escape(toUri)}[/]"); + AnsiConsole.MarkupLine( + $"[red]✗ Destination alias/index [white]{Markup.Escape(alias)}[/] does not exist on {Markup.Escape(toUri)}[/]" + ); AnsiConsole.MarkupLine("[dim]Create it first (e.g. run [white]indices unify[/] on the destination cluster).[/]"); Environment.Exit(1); return; @@ -913,7 +1084,9 @@ public async Task SyncRemote( // Fail fast before starting the expensive reindex. if (!SearchResourceSynchronizer.TryDeriveEnvironment(alias, out var syncEnv)) { - AnsiConsole.MarkupLine($"[red]✗ Cannot derive environment from alias '{Markup.Escape(alias)}' — cannot copy search resources.[/]"); + AnsiConsole.MarkupLine( + $"[red]✗ Cannot derive environment from alias '{Markup.Escape(alias)}' — cannot copy search resources.[/]" + ); AnsiConsole.MarkupLine("[dim]Alias must follow the pattern .(lexical|semantic)--latest or ws-content-.[/]"); Environment.Exit(1); return; @@ -932,7 +1105,7 @@ public async Task SyncRemote( } var destIndex = await ResolveAliasIndexAsync(transport, alias, ct) ?? alias; - var sourceCount = await CountAsync(transport, alias, /*match_all*/ null, ct); + var sourceCount = await CountAsync(transport, alias, /*match_all*/ null, ct); AnsiConsole.MarkupLine("[aqua bold]Indices sync-remote[/]"); AnsiConsole.MarkupLine($"[dim]From (source):[/] {Markup.Escape(fromEndpoint.Uri.ToString())}"); @@ -940,21 +1113,28 @@ public async Task SyncRemote( AnsiConsole.MarkupLine($"[dim] source docs:[/] [white]{sourceCount:N0}[/]"); AnsiConsole.MarkupLine($"[dim]To (dest):[/] {Markup.Escape(toUri)}"); AnsiConsole.MarkupLine($"[dim] alias → index:[/] [white]{Markup.Escape(alias)}[/] → [white]{Markup.Escape(destIndex)}[/]"); - AnsiConsole.MarkupLine($"[dim]Slices:[/] [white]{Markup.Escape(slicesStr)}[/] [dim]rps:[/] [white]{(options.Rps.HasValue ? options.Rps.Value.ToString("F0") : "unlimited")}[/]"); + AnsiConsole.MarkupLine( + $"[dim]Slices:[/] [white]{Markup.Escape(slicesStr)}[/] [dim]rps:[/] [white]{(options.Rps.HasValue ? options.Rps.Value.ToString("F0") : "unlimited")}[/]" + ); AnsiConsole.MarkupLine($"[dim]Strip sem. fields:[/][white]{stripSemanticFields}[/]"); AnsiConsole.WriteLine(); var remoteSource = BuildRemoteSource(fromEndpoint); - if (!await RunServerReindexAsync(transport, - new ServerReindexOptions - { - Remote = remoteSource, - Source = alias, - Destination = alias, - RequestsPerSecond = options.Rps, - ExcludeInferenceFields = stripSemanticFields, - }, - $"sync-remote ({Markup.Escape(alias)})", ct)) + if ( + !await RunServerReindexAsync( + transport, + new ServerReindexOptions + { + Remote = remoteSource, + Source = alias, + Destination = alias, + RequestsPerSecond = options.Rps, + ExcludeInferenceFields = stripSemanticFields, + }, + $"sync-remote ({Markup.Escape(alias)})", + ct + ) + ) { AnsiConsole.MarkupLine("[red]Aborting — remote reindex failed.[/]"); Environment.Exit(1); @@ -1018,7 +1198,8 @@ public async Task Copy( bool force = false, bool stripSemanticFields = false, [AsParameters] IndicesRemoteSyncOptions options = default!, - Cancel ct = default) + Cancel ct = default + ) { if (indices.Length == 0) { @@ -1035,7 +1216,9 @@ public async Task Copy( AnsiConsole.MarkupLine("[aqua bold]Indices copy[/]"); AnsiConsole.MarkupLine($"[dim]From (source):[/] {Markup.Escape(fromEndpoint.Uri.ToString())}"); AnsiConsole.MarkupLine($"[dim]To (dest):[/] {Markup.Escape(toUri)}"); - AnsiConsole.MarkupLine($"[dim]rps:[/] [white]{(options.Rps.HasValue ? options.Rps.Value.ToString("F0") : "unlimited")}[/]"); + AnsiConsole.MarkupLine( + $"[dim]rps:[/] [white]{(options.Rps.HasValue ? options.Rps.Value.ToString("F0") : "unlimited")}[/]" + ); AnsiConsole.MarkupLine($"[dim]Force:[/] [white]{force}[/] [dim]Strip sem. fields:[/] [white]{stripSemanticFields}[/]"); AnsiConsole.WriteLine(); @@ -1047,14 +1230,18 @@ public async Task Copy( { var destIndex = ApplyRename(source, renameFrom, renameTo); var renamed = destIndex != source; - AnsiConsole.MarkupLine(renamed - ? $"[aqua]{Markup.Escape(source)}[/] [dim](renamed →)[/] [aqua]{Markup.Escape(destIndex)}[/]" - : $"[aqua]{Markup.Escape(source)}[/]"); + AnsiConsole.MarkupLine( + renamed + ? $"[aqua]{Markup.Escape(source)}[/] [dim](renamed →)[/] [aqua]{Markup.Escape(destIndex)}[/]" + : $"[aqua]{Markup.Escape(source)}[/]" + ); var sourceHead = await fromTransport.HeadAsync(source, ct); if (sourceHead.ApiCallDetails.HttpStatusCode != 200) { - AnsiConsole.MarkupLine($"[red]✗ Source index [white]{Markup.Escape(source)}[/] not found on {Markup.Escape(fromEndpoint.Uri.ToString())}[/]"); + AnsiConsole.MarkupLine( + $"[red]✗ Source index [white]{Markup.Escape(source)}[/] not found on {Markup.Escape(fromEndpoint.Uri.ToString())}[/]" + ); failed++; continue; } @@ -1074,16 +1261,21 @@ public async Task Copy( // Slices is intentionally omitted — Elasticsearch rejects slices > 1 for reindex // from a remote source ("reindex from remote sources doesn't support slices > 1"). - if (!await RunServerReindexAsync(transport, - new ServerReindexOptions - { - Remote = remoteSource, - Source = source, - Destination = destIndex, - RequestsPerSecond = options.Rps, - ExcludeInferenceFields = stripSemanticFields, - }, - $"copy ({Markup.Escape(source)})", ct)) + if ( + !await RunServerReindexAsync( + transport, + new ServerReindexOptions + { + Remote = remoteSource, + Source = source, + Destination = destIndex, + RequestsPerSecond = options.Rps, + ExcludeInferenceFields = stripSemanticFields, + }, + $"copy ({Markup.Escape(source)})", + ct + ) + ) { AnsiConsole.MarkupLine($"[red]✗ Copy failed for {Markup.Escape(source)}.[/]"); failed++; @@ -1167,7 +1359,8 @@ public async Task UnifyIncrementalSync( string? renameTo = null, string? renameToLexical = null, [AsParameters] IndicesRemoteSyncOptions options = default!, - Cancel ct = default) + Cancel ct = default + ) { var (fromEndpoint, transport, toUri) = ResolveSyncTransport(options); var slicesStr = options.Slices.ToString(); @@ -1177,8 +1370,10 @@ public async Task UnifyIncrementalSync( // ── Validate destination targets ────────────────────────────────────────── // Both aliases must exist and expose the tracking fields used for incremental sync. - if (!await IndexHasFieldsAsync(transport, lexicalAlias, ["batch_index_date", "last_updated"], ct) - || !await IndexHasFieldsAsync(transport, semanticAlias, ["last_updated"], ct)) + if ( + !await IndexHasFieldsAsync(transport, lexicalAlias, ["batch_index_date", "last_updated"], ct) || + !await IndexHasFieldsAsync(transport, semanticAlias, ["last_updated"], ct) + ) { Environment.Exit(1); return; @@ -1191,7 +1386,9 @@ public async Task UnifyIncrementalSync( var fromTransport = ElasticsearchTransportFactory.Create(fromEndpoint); if (!SearchResourceSynchronizer.TryDeriveEnvironment(semanticAlias, out var syncEnv)) { - AnsiConsole.MarkupLine($"[red]✗ Cannot derive environment from alias '{Markup.Escape(semanticAlias)}' — cannot copy search resources.[/]"); + AnsiConsole.MarkupLine( + $"[red]✗ Cannot derive environment from alias '{Markup.Escape(semanticAlias)}' — cannot copy search resources.[/]" + ); AnsiConsole.MarkupLine("[dim]Alias must follow the pattern .(lexical|semantic)--latest.[/]"); Environment.Exit(1); return; @@ -1214,11 +1411,11 @@ public async Task UnifyIncrementalSync( var resolvedLexicalIndex = await ResolveAliasIndexAsync(transport, lexicalAlias, ct); var lexicalIndex = ApplyRename( resolvedLexicalIndex ?? DeriveBackingIndexName(lexicalAlias, batchTs), - renameFrom, renameToLexical ?? renameTo); + renameFrom, + renameToLexical ?? renameTo + ); var resolvedSemanticIndex = await ResolveAliasIndexAsync(transport, semanticAlias, ct); - var semanticIndex = ApplyRename( - resolvedSemanticIndex ?? DeriveBackingIndexName(semanticAlias, batchTs), - renameFrom, renameTo); + var semanticIndex = ApplyRename(resolvedSemanticIndex ?? DeriveBackingIndexName(semanticAlias, batchTs), renameFrom, renameTo); // True when the destination index doesn't exist yet and must be created from source mapping. // Lexical is plain-text — ES dynamic mapping is sufficient, no bootstrap needed. var semanticIsNew = resolvedSemanticIndex is null || semanticIndex != resolvedSemanticIndex; @@ -1240,7 +1437,9 @@ public async Task UnifyIncrementalSync( ? $"{Markup.Escape(resolvedSemanticIndex)} [dim](renamed →)[/] {Markup.Escape(semanticIndex)}" : Markup.Escape(semanticIndex); AnsiConsole.MarkupLine($"[dim] semantic → index:[/] [white]{Markup.Escape(semanticAlias)}[/] → [white]{semanticIndexDisplay}[/]"); - AnsiConsole.MarkupLine($"[dim]Slices:[/] [white]{Markup.Escape(slicesStr)}[/] [dim]rps:[/] [white]{(options.Rps.HasValue ? options.Rps.Value.ToString("F0") : "unlimited")}[/]"); + AnsiConsole.MarkupLine( + $"[dim]Slices:[/] [white]{Markup.Escape(slicesStr)}[/] [dim]rps:[/] [white]{(options.Rps.HasValue ? options.Rps.Value.ToString("F0") : "unlimited")}[/]" + ); AnsiConsole.MarkupLine($"[dim]batch_index_date (this run):[/] [white]{batchTs:o}[/]"); AnsiConsole.MarkupLine($"[dim]cutoff (max last_updated in semantic):[/] [white]{cutoff:o}[/]"); AnsiConsole.WriteLine(); @@ -1249,19 +1448,28 @@ public async Task UnifyIncrementalSync( // Fast bulk copy — lexical has no semantic_text fields → no inference. // Stamps batch_index_date = batchTs on every doc so the mark-and-sweep can find deletes. // Docs removed from prod are NOT restamped → their batch_index_date < batchTs after this step. - var stampScript = "{\"lang\":\"painless\",\"source\":\"ctx._source.batch_index_date = params.ts;\",\"params\":{\"ts\":\"" + batchTsStr + "\"}}"; - AnsiConsole.MarkupLine($"[dim]Phase 1:[/] remote-fill [white]{Markup.Escape(lexicalAlias)}[/] → [white]{Markup.Escape(lexicalIndex)}[/]"); + var stampScript = "{\"lang\":\"painless\",\"source\":\"ctx._source.batch_index_date = params.ts;\",\"params\":{\"ts\":\"" + + batchTsStr + + "\"}}"; + AnsiConsole.MarkupLine( + $"[dim]Phase 1:[/] remote-fill [white]{Markup.Escape(lexicalAlias)}[/] → [white]{Markup.Escape(lexicalIndex)}[/]" + ); var remoteSource = BuildRemoteSource(fromEndpoint); - if (!await RunServerReindexAsync(transport, - new ServerReindexOptions - { - Remote = remoteSource, - Source = lexicalAlias, - Destination = lexicalIndex, - Script = stampScript, - RequestsPerSecond = options.Rps, - }, - "remote-lexical-fill", ct)) + if ( + !await RunServerReindexAsync( + transport, + new ServerReindexOptions + { + Remote = remoteSource, + Source = lexicalAlias, + Destination = lexicalIndex, + Script = stampScript, + RequestsPerSecond = options.Rps, + }, + "remote-lexical-fill", + ct + ) + ) { AnsiConsole.MarkupLine("[red]Aborting — remote lexical fill failed.[/]"); Environment.Exit(1); @@ -1281,8 +1489,12 @@ public async Task UnifyIncrementalSync( var toDelete = await CountAsync(transport, lexicalIndex, toDeleteQuery, ct); AnsiConsole.MarkupLine("[dim]Phase 2 preview:[/]"); - AnsiConsole.MarkupLine($" [green]→[/] [white]{toSync:N0}[/] [dim]docs to (re)index into [white]{Markup.Escape(semanticAlias)}[/] (last_updated > {cutoff:o})[/]"); - AnsiConsole.MarkupLine($" [red]←[/] [white]{toDelete:N0}[/] [dim]docs to delete from [white]{Markup.Escape(semanticAlias)}[/] + [white]{Markup.Escape(lexicalAlias)}[/] (batch_index_date < {batchTs:o})[/]"); + AnsiConsole.MarkupLine( + $" [green]→[/] [white]{toSync:N0}[/] [dim]docs to (re)index into [white]{Markup.Escape(semanticAlias)}[/] (last_updated > {cutoff:o})[/]" + ); + AnsiConsole.MarkupLine( + $" [red]←[/] [white]{toDelete:N0}[/] [dim]docs to delete from [white]{Markup.Escape(semanticAlias)}[/] + [white]{Markup.Escape(lexicalAlias)}[/] (batch_index_date < {batchTs:o})[/]" + ); AnsiConsole.WriteLine(); // ── Phase 2a: Incremental inference reindex ─────────────────────────────── @@ -1293,14 +1505,25 @@ public async Task UnifyIncrementalSync( await BootstrapSemanticIndexAsync(fromTransport, transport, semanticAlias, semanticIndex, logger, ct); } const string semanticSlices = "1"; - AnsiConsole.MarkupLine($"[dim]Phase 2a:[/] inference-reindex [white]{Markup.Escape(lexicalAlias)}[/] → [white]{Markup.Escape(semanticIndex)}[/]"); - var inferenceBody = - "{\"source\":{\"index\":\"" + lexicalIndex + - "\",\"query\":" + toSyncQuery + "}," + - "\"dest\":{\"index\":\"" + semanticIndex + "\"}}"; - if (!await RunServerReindexAsync(transport, - new ServerReindexOptions { Body = inferenceBody, Slices = semanticSlices, RequestsPerSecond = options.Rps }, - "inference-reindex", ct)) + AnsiConsole.MarkupLine( + $"[dim]Phase 2a:[/] inference-reindex [white]{Markup.Escape(lexicalAlias)}[/] → [white]{Markup.Escape(semanticIndex)}[/]" + ); + var inferenceBody = "{\"source\":{\"index\":\"" + + lexicalIndex + + "\",\"query\":" + + toSyncQuery + + "}," + + "\"dest\":{\"index\":\"" + + semanticIndex + + "\"}}"; + if ( + !await RunServerReindexAsync( + transport, + new ServerReindexOptions { Body = inferenceBody, Slices = semanticSlices, RequestsPerSecond = options.Rps }, + "inference-reindex", + ct + ) + ) { AnsiConsole.MarkupLine("[red]Aborting — inference reindex failed.[/]"); Environment.Exit(1); @@ -1309,11 +1532,18 @@ public async Task UnifyIncrementalSync( // ── Phase 2b: Delete step — remove docs gone from prod ──────────────────── // Reindex stale lexical docs (batch_index_date < batchTs) to semantic with a delete script. - AnsiConsole.MarkupLine($"[dim]Phase 2b:[/] delete-reindex [white]{Markup.Escape(lexicalAlias)}[/] → [white]{Markup.Escape(semanticIndex)}[/]"); + AnsiConsole.MarkupLine( + $"[dim]Phase 2b:[/] delete-reindex [white]{Markup.Escape(lexicalAlias)}[/] → [white]{Markup.Escape(semanticIndex)}[/]" + ); var deleteBody = BuildDeleteScriptBody(lexicalIndex, semanticIndex, batchTsStr); - if (!await RunServerReindexAsync(transport, - new ServerReindexOptions { Body = deleteBody, Slices = slicesStr }, - "reindex-deletes", ct)) + if ( + !await RunServerReindexAsync( + transport, + new ServerReindexOptions { Body = deleteBody, Slices = slicesStr }, + "reindex-deletes", + ct + ) + ) { AnsiConsole.MarkupLine("[red]Aborting — delete step failed.[/]"); Environment.Exit(1); @@ -1390,8 +1620,7 @@ public async Task Cleanup([AsParameters] IndicesCleanupOptions options, Cancel c return; } - AnsiConsole.MarkupLine( - $"[red]Error fetching index aliases:[/] HTTP {aliasResponse.ApiCallDetails.HttpStatusCode}"); + AnsiConsole.MarkupLine($"[red]Error fetching index aliases:[/] HTTP {aliasResponse.ApiCallDetails.HttpStatusCode}"); Environment.Exit(1); return; } @@ -1437,14 +1666,16 @@ public async Task Cleanup([AsParameters] IndicesCleanupOptions options, Cancel c { failed++; AnsiConsole.MarkupLine( - $"[red]Failed to delete[/] [white]{Markup.Escape(index.Name)}[/] — HTTP {deleteResponse.ApiCallDetails.HttpStatusCode}"); + $"[red]Failed to delete[/] [white]{Markup.Escape(index.Name)}[/] — HTTP {deleteResponse.ApiCallDetails.HttpStatusCode}" + ); } } AnsiConsole.WriteLine(); AnsiConsole.MarkupLine( $"[green]✓[/] Done — kept [white]{plan.ToKeep.Count}[/], deleted [white]{deleted}[/]" + - (failed > 0 ? $", [red]failed {failed}[/]" : "")); + (failed > 0 ? $", [red]failed {failed}[/]" : "") + ); if (failed > 0) Environment.Exit(1); @@ -1470,7 +1701,8 @@ private static IReadOnlyDictionary> ParseAliasRespo private static void RenderPlanTable(CleanupPlan plan) { var keepSet = plan.ToKeep.ToHashSet(); - var all = plan.ToKeep.Concat(plan.ToDelete) + var all = plan.ToKeep + .Concat(plan.ToDelete) .OrderBy(b => b.Group.Source) .ThenBy(b => b.Group.Variant) .ThenByDescending(b => b.Date) @@ -1497,28 +1729,24 @@ private static void RenderPlanTable(CleanupPlan plan) _ = table.AddEmptyRow(); lastGroup = currentGroup; - var action = idx.IsActive - ? "[green]KEEP-ACTIVE[/]" - : keepSet.Contains(idx) - ? "[grey]KEEP[/]" - : "[red]DELETE[/]"; - _ = table.AddRow( - Markup.Escape(idx.Group.Source), - Markup.Escape(idx.Group.Variant), - Markup.Escape(idx.Group.Environment), - idx.Date.ToString("yyyy-MM-dd HH:mm:ss"), - action); + var action = idx.IsActive ? "[green]KEEP-ACTIVE[/]" : keepSet.Contains(idx) ? "[grey]KEEP[/]" : "[red]DELETE[/]"; + _ = + table.AddRow( + Markup.Escape(idx.Group.Source), + Markup.Escape(idx.Group.Variant), + Markup.Escape(idx.Group.Environment), + idx.Date.ToString("yyyy-MM-dd HH:mm:ss"), + action + ); } AnsiConsole.Write(table); AnsiConsole.WriteLine(); } - // ─── Remote sync helpers ──────────────────────────────────────────────────── - private (ElasticsearchEndpoint From, DistributedTransport ToTransport, string ToUri) ResolveSyncTransport( - IndicesRemoteSyncOptions o) + private (ElasticsearchEndpoint From, DistributedTransport ToTransport, string ToUri) ResolveSyncTransport(IndicesRemoteSyncOptions o) { var from = ResolveEndpoint(o.FromUrl, o.FromApiKey); @@ -1539,7 +1767,12 @@ private static void RenderPlanTable(CleanupPlan plan) } private async Task ApplyAliasesAsync( - DistributedTransport transport, string? applyAliases, string destIndex, ILogger logger, CancellationToken ct) + DistributedTransport transport, + string? applyAliases, + string destIndex, + ILogger logger, + CancellationToken ct + ) { if (string.IsNullOrWhiteSpace(applyAliases)) return; @@ -1572,16 +1805,22 @@ private static RemoteSource BuildRemoteSource(ElasticsearchEndpoint from) /// field is absent from its mapping. /// private static async Task IndexHasFieldsAsync( - DistributedTransport transport, string target, string[] requiredFields, CancellationToken ct) + DistributedTransport transport, + string target, + string[] requiredFields, + CancellationToken ct + ) { var csv = string.Join(",", requiredFields); // Use StringResponse + plain string check to avoid Get on object-typed nodes. // _field_caps returns {"fields":{"":{...}}} — checking for the key name as a JSON key // is sufficient to confirm the field is mapped. - var resp = await transport.RequestAsync( - Transport.HttpMethod.GET, - $"{Uri.EscapeDataString(target)}/_field_caps?fields={csv}&filter_path=fields", - cancellationToken: ct); + var resp = + await transport.RequestAsync( + Transport.HttpMethod.GET, + $"{Uri.EscapeDataString(target)}/_field_caps?fields={csv}&filter_path=fields", + cancellationToken: ct + ); if (!resp.ApiCallDetails.HasSuccessfulStatusCode) { @@ -1595,7 +1834,8 @@ private static async Task IndexHasFieldsAsync( // null status = connection-level failure (DNS, TLS, timeout) — hard fail. AnsiConsole.MarkupLine( $"[red]✗ Destination target [white]{Markup.Escape(target)}[/] not found or inaccessible " + - $"(HTTP {status?.ToString() ?? "connection error"}).[/]"); + $"(HTTP {status?.ToString() ?? "connection error"}).[/]" + ); return false; } @@ -1607,10 +1847,12 @@ private static async Task IndexHasFieldsAsync( if (!body.Contains($"\"{field}\":", StringComparison.Ordinal)) { AnsiConsole.MarkupLine( - $"[red]✗ Field [white]{Markup.Escape(field)}[/] not found in mapping of [white]{Markup.Escape(target)}[/].[/]"); + $"[red]✗ Field [white]{Markup.Escape(field)}[/] not found in mapping of [white]{Markup.Escape(target)}[/].[/]" + ); AnsiConsole.MarkupLine( $"[dim] [white]unify-incremental-sync[/] requires [white]{Markup.Escape(field)}[/] " + - $"for incremental tracking. Run [white]indices unify[/] on the destination first.[/]"); + $"for incremental tracking. Run [white]indices unify[/] on the destination first.[/]" + ); ok = false; } } @@ -1621,15 +1863,16 @@ private static async Task IndexHasFieldsAsync( /// Returns the document count for matching /// (a JSON query object, or null for match-all). /// - private static async Task CountAsync( - DistributedTransport transport, string target, string? queryBody, CancellationToken ct) + private static async Task CountAsync(DistributedTransport transport, string target, string? queryBody, CancellationToken ct) { var body = queryBody is null ? null : PostData.String("{\"query\":" + queryBody + "}"); - var resp = await transport.RequestAsync( - Transport.HttpMethod.POST, - $"{Uri.EscapeDataString(target)}/_count", - body, - cancellationToken: ct); + var resp = + await transport.RequestAsync( + Transport.HttpMethod.POST, + $"{Uri.EscapeDataString(target)}/_count", + body, + cancellationToken: ct + ); return resp.Get("count") ?? 0; } diff --git a/src/tooling/essc/Commands/LabsCommands.cs b/src/tooling/essc/Commands/LabsCommands.cs index df011d7b7c..464cc4e404 100644 --- a/src/tooling/essc/Commands/LabsCommands.cs +++ b/src/tooling/essc/Commands/LabsCommands.cs @@ -85,8 +85,7 @@ CrawlerSettings crawlerSettings ) { private static bool IsInteractive() => - string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) && - AnsiConsole.Profile.Capabilities.Interactive; + string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) && AnsiConsole.Profile.Capabilities.Interactive; /// /// Discover labs URLs from sitemaps, crawl HTML, and bulk-ingest into Elasticsearch. @@ -151,11 +150,13 @@ public async Task Sync([AsParameters] LabsSyncOptions options, Cancel ct = defau else { AnsiConsole.MarkupLine("[aqua]Fetching labs sitemaps...[/]"); - discovery = await LabsSiteCrawlPlanner.DiscoverUrlsAsync( - sitemapParser, - LabsSiteCrawlPlanner.LabsSitemapUrls, - new Progress<(int completed, string currentSitemap)>(_ => { }), - ct); + discovery = + await LabsSiteCrawlPlanner.DiscoverUrlsAsync( + sitemapParser, + LabsSiteCrawlPlanner.LabsSitemapUrls, + new Progress<(int completed, string currentSitemap)>(_ => { }), + ct + ); } var allUrls = discovery.Urls; @@ -163,11 +164,9 @@ public async Task Sync([AsParameters] LabsSyncOptions options, Cancel ct = defau foreach (var (sitemapUrl, rawUrlCount) in discovery.PerSitemap) { var label = LabsSiteCrawlPlanner.SitemapDisplayLabel(sitemapUrl); - AnsiConsole.MarkupLine( - $" [dim]{Markup.Escape(label)}[/] [white]{rawUrlCount:N0}[/] [dim]URLs (from sitemap)[/]"); + AnsiConsole.MarkupLine($" [dim]{Markup.Escape(label)}[/] [white]{rawUrlCount:N0}[/] [dim]URLs (from sitemap)[/]"); } - AnsiConsole.MarkupLine( - $"[green]✓[/] [white]{allUrls.Count:N0}[/] [dim]unique URLs[/] [dim](cross-sitemap duplicates merged)[/]"); + AnsiConsole.MarkupLine($"[green]✓[/] [white]{allUrls.Count:N0}[/] [dim]unique URLs[/] [dim](cross-sitemap duplicates merged)[/]"); if (allUrls.Count == 0) return; @@ -183,13 +182,14 @@ public async Task Sync([AsParameters] LabsSyncOptions options, Cancel ct = defau var cache = force ? [with(StringComparer.OrdinalIgnoreCase)] : !await crawlCache.IndexExistsAsync(indexAlias, ct) - ? [with(StringComparer.OrdinalIgnoreCase)] - : await crawlCache.LoadCacheAsync(indexAlias, progress: null, ct); + ? [with(StringComparer.OrdinalIgnoreCase)] + : await crawlCache.LoadCacheAsync(indexAlias, progress: null, ct); var plan = LabsSiteCrawlPlanner.BuildCrawlPlan(filtered, cache, unchanged, fair, maxPages, loggerFactory); AnsiConsole.MarkupLine( - $"[dim]new {plan.Stats.NewUrls:N0} | unchanged {plan.Stats.UnchangedUrls:N0} | verify {plan.Stats.PossiblyChangedUrls:N0} | crawl ops {plan.UrlsToCrawl.Count:N0}[/]"); + $"[dim]new {plan.Stats.NewUrls:N0} | unchanged {plan.Stats.UnchangedUrls:N0} | verify {plan.Stats.PossiblyChangedUrls:N0} | crawl ops {plan.UrlsToCrawl.Count:N0}[/]" + ); if (dryRun) { @@ -201,13 +201,7 @@ public async Task Sync([AsParameters] LabsSyncOptions options, Cancel ct = defau LabsDocumentExporter? exporter = null; try { - exporter = new LabsDocumentExporter( - loggerFactory, - cfg, - transport, - buildType, - env, - enableAiEnrichment: !noAi); + exporter = new LabsDocumentExporter(loggerFactory, cfg, transport, buildType, env, enableAiEnrichment: !noAi); if (!noAi) exporter.ConfigurePostSyncAiBatch(options.MaxAiDocs, options.MaxAiTime); @@ -231,7 +225,8 @@ await AnsiConsole.Status() crawler, new LabsHtmlExtractor(loggerFactory.CreateLogger()), exporter, - loggerFactory.CreateLogger()); + loggerFactory.CreateLogger() + ); var done = 0; var total = decisions.Count; @@ -277,11 +272,7 @@ void WireCrawlHandlers(Action bumpProgress) if (IsInteractive() && total > 0) { await AnsiConsole.Progress() - .Columns( - new SpinnerColumn(), - new TaskDescriptionColumn(), - new ProgressBarColumn(), - new PercentageColumn()) + .Columns(new SpinnerColumn(), new TaskDescriptionColumn(), new ProgressBarColumn(), new PercentageColumn()) .StartAsync(async pc => { var t = pc.AddTask("[aqua]Crawl[/]", maxValue: Math.Max(1, total)); @@ -305,13 +296,13 @@ void ProgressBump() } AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine( - $"[green]✓[/] Crawl phase [white]{done:N0}[/][dim]/[/][white]{total:N0}[/] [dim]URLs handled[/]"); + AnsiConsole.MarkupLine($"[green]✓[/] Crawl phase [white]{done:N0}[/][dim]/[/][white]{total:N0}[/] [dim]URLs handled[/]"); WriteCrawlOutcomeSummary(crawlOutcomes); if (done != total) { AnsiConsole.MarkupLine( - $"[yellow]! [/][dim]Handled count ({done}) != crawl ops ({total}). Cancelled, or fewer HTTP results than tasks — check logs.[/]"); + $"[yellow]! [/][dim]Handled count ({done}) != crawl ops ({total}). Cancelled, or fewer HTTP results than tasks — check logs.[/]" + ); } if (IsInteractive()) @@ -319,11 +310,14 @@ void ProgressBump() await AnsiConsole.Status() .AutoRefresh(true) .Spinner(Spinner.Known.Dots) - .StartAsync("[aqua]Finalizing…[/]", async ctx => - { - exporter.OnSyncProgress = info => ctx.Status(SyncProgressConsole.FormatStatusMarkup(info)); - await exporter.FinalizeAsync(ct); - }); + .StartAsync( + "[aqua]Finalizing…[/]", + async ctx => + { + exporter.OnSyncProgress = info => ctx.Status(SyncProgressConsole.FormatStatusMarkup(info)); + await exporter.FinalizeAsync(ct); + } + ); } else { @@ -402,10 +396,13 @@ public async Task AiEnrich( await AnsiConsole.Status() .AutoRefresh(true) .Spinner(Spinner.Known.Dots) - .StartAsync("[aqua]Bootstrapping Elasticsearch indices...[/]", async _ => - { - await exporter.StartAsync(effectiveToken); - }); + .StartAsync( + "[aqua]Bootstrapping Elasticsearch indices...[/]", + async _ => + { + await exporter.StartAsync(effectiveToken); + } + ); AnsiConsole.MarkupLine($"[green]✓[/] Elasticsearch indices ready [dim]({exporter.Strategy})[/]"); WriteBootstrapSummary(exporter); @@ -417,11 +414,13 @@ await AnsiConsole.Status() return; } - var aiResult = await AiEnrichmentConsole.RunInteractiveAsync( - exporter.AiEnrichmentEnabled, - (max, token) => exporter.RunAiEnrichmentAsync(max, token), - maxAiDocs, - effectiveToken); + var aiResult = + await AiEnrichmentConsole.RunInteractiveAsync( + exporter.AiEnrichmentEnabled, + (max, token) => exporter.RunAiEnrichmentAsync(max, token), + maxAiDocs, + effectiveToken + ); AiEnrichmentConsole.DisplaySummary(aiResult, maxAiTime, maxAiDocs); } catch (OperationCanceledException) when (deadline.TimedOut) @@ -476,8 +475,7 @@ private static void WriteCrawlOutcomeSummary(CrawlOutcomeCounters c) _ = table.AddRow(new Markup("Index error"), new Markup(CountRed(c.IndexingFailed))); _ = table.AddRow(new Markup("Fatal"), new Markup(CountRed(c.FatalCrawlErrors))); - var total = c.Indexed + c.NotModified + c.ExtractSkipped + c.CrawlFailed + c.Unavailable + - c.IndexingFailed + c.FatalCrawlErrors; + var total = c.Indexed + c.NotModified + c.ExtractSkipped + c.CrawlFailed + c.Unavailable + c.IndexingFailed + c.FatalCrawlErrors; _ = table.AddRow(new Markup("[bold]Total[/]"), new Markup($"[bold]{total:N0}[/]")); AnsiConsole.Write(table); @@ -490,23 +488,21 @@ private static void WriteCrawlOutcomeSummary(CrawlOutcomeCounters c) await AnsiConsole.Progress() .AutoRefresh(true) .AutoClear(false) - .Columns( - new SpinnerColumn(), - new TaskDescriptionColumn(), - new ProgressBarColumn(), - new PercentageColumn()) + .Columns(new SpinnerColumn(), new TaskDescriptionColumn(), new ProgressBarColumn(), new PercentageColumn()) .StartAsync(async pc => { var task = pc.AddTask("[aqua]Labs sitemaps[/]", maxValue: LabsSiteCrawlPlanner.LabsSitemapUrls.Length); - result = await LabsSiteCrawlPlanner.DiscoverUrlsAsync( - sitemapParser, - LabsSiteCrawlPlanner.LabsSitemapUrls, - new Progress<(int completed, string currentSitemap)>(v => - { - task.Description = $"[aqua]{Markup.Escape(v.currentSitemap)}[/]"; - task.Value = v.completed; - }), - ct); + result = + await LabsSiteCrawlPlanner.DiscoverUrlsAsync( + sitemapParser, + LabsSiteCrawlPlanner.LabsSitemapUrls, + new Progress<(int completed, string currentSitemap)>(v => + { + task.Description = $"[aqua]{Markup.Escape(v.currentSitemap)}[/]"; + task.Value = v.completed; + }), + ct + ); }); return result ?? throw new InvalidOperationException("Labs sitemap discovery did not return a result."); } @@ -514,8 +510,9 @@ await AnsiConsole.Progress() private static HashSet ParseLanguageFilter(string? languages) => string.IsNullOrWhiteSpace(languages) ? [] - : languages.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .ToHashSet(StringComparer.OrdinalIgnoreCase); + : languages.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToHashSet( + StringComparer.OrdinalIgnoreCase + ); private ElasticsearchEndpoint ResolveEndpoint(Uri? endpoint, string? apiKey) { diff --git a/src/tooling/essc/Commands/SyncCommand.cs b/src/tooling/essc/Commands/SyncCommand.cs index 47b15432bc..1105c26991 100644 --- a/src/tooling/essc/Commands/SyncCommand.cs +++ b/src/tooling/essc/Commands/SyncCommand.cs @@ -12,18 +12,13 @@ namespace Elastic.SiteSearch.Cli.Commands; -internal sealed class SyncCommand( - ContentStackClient client, - SourcingConfiguration config, - ILoggerFactory loggerFactory -) +internal sealed class SyncCommand(ContentStackClient client, SourcingConfiguration config, ILoggerFactory loggerFactory) { private const string StateFile = "sync-state.json"; private const int LaneCount = 5; private static bool IsInteractive() => - string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) && - AnsiConsole.Profile.Capabilities.Interactive; + string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) && AnsiConsole.Profile.Capabilities.Interactive; /// /// Sync published entries from Contentstack and index to Elasticsearch. @@ -70,8 +65,7 @@ public async Task Sync( if (force) store.Delete(StateFile); - var cursorMap = store.Load(StateFile, StateJsonContext.Default.SyncCursorMap) - ?? new SyncCursorMap(); + var cursorMap = store.Load(StateFile, StateJsonContext.Default.SyncCursorMap) ?? new SyncCursorMap(); var cfg = ResolveEndpoint(endpoint, apiKey); @@ -90,7 +84,8 @@ public async Task Sync( if (resumeCount > 0 || deltaCount > 0) { AnsiConsole.MarkupLine( - $"[dim]Fresh: [white]{freshCount}[/] | Delta: [white]{deltaCount}[/] | Resume: [white]{resumeCount}[/][/]"); + $"[dim]Fresh: [white]{freshCount}[/] | Delta: [white]{deltaCount}[/] | Resume: [white]{resumeCount}[/][/]" + ); } AnsiConsole.WriteLine(); @@ -99,14 +94,15 @@ public async Task Sync( if (!noIndex) { var transport = ElasticsearchTransportFactory.Create(cfg); - exporter = new SiteDocumentExporter( - loggerFactory, - cfg, - transport, - config.BuildType, - config.ElasticsearchEnvironment, - enableAiEnrichment: !noAi - ); + exporter = + new SiteDocumentExporter( + loggerFactory, + cfg, + transport, + config.BuildType, + config.ElasticsearchEnvironment, + enableAiEnrichment: !noAi + ); if (!noAi) exporter.ConfigurePostSyncAiBatch(maxAiDocs, maxAiTime); @@ -116,10 +112,13 @@ public async Task Sync( await AnsiConsole.Status() .AutoRefresh(true) .Spinner(Spinner.Known.Dots) - .StartAsync("[aqua]Bootstrapping Elasticsearch indices...[/]", async _ => - { - await exporter.StartAsync(ct); - }); + .StartAsync( + "[aqua]Bootstrapping Elasticsearch indices...[/]", + async _ => + { + await exporter.StartAsync(ct); + } + ); } else { @@ -164,15 +163,33 @@ await AnsiConsole.Progress() { var overallTask = ctx.AddTask( $"[aqua]Overall[/] — [white]0[/]/{PageContentTypes.All.Length} types", - maxValue: PageContentTypes.All.Length); - - var lanes = Enumerable.Range(0, LaneCount).Select(i => - { - var laneTask = ctx.AddTask($"[dim]Lane {i + 1}: idle[/]", maxValue: 100); - laneTask.IsIndeterminate = true; - return RunLaneAsync(i + 1, laneTask, channel.Reader, cursorMap, runCounts, indexedCounts, skippedCounts, - duplicateCounts, localeCounts, pagePer, exporter, store, writeSemaphore, overallTask, ct); - }).ToArray(); + maxValue: PageContentTypes.All.Length + ); + + var lanes = Enumerable.Range(0, LaneCount) + .Select(i => + { + var laneTask = ctx.AddTask($"[dim]Lane {i + 1}: idle[/]", maxValue: 100); + laneTask.IsIndeterminate = true; + return RunLaneAsync( + i + 1, + laneTask, + channel.Reader, + cursorMap, + runCounts, + indexedCounts, + skippedCounts, + duplicateCounts, + localeCounts, + pagePer, + exporter, + store, + writeSemaphore, + overallTask, + ct + ); + }) + .ToArray(); await Task.WhenAll(lanes); @@ -182,10 +199,28 @@ await AnsiConsole.Progress() } else { - var lanes = Enumerable.Range(0, LaneCount).Select(i => - RunLaneAsync(i + 1, null, channel.Reader, cursorMap, runCounts, indexedCounts, skippedCounts, - duplicateCounts, localeCounts, pagePer, exporter, store, writeSemaphore, null, ct) - ).ToArray(); + var lanes = Enumerable.Range(0, LaneCount) + .Select( + i => + RunLaneAsync( + i + 1, + null, + channel.Reader, + cursorMap, + runCounts, + indexedCounts, + skippedCounts, + duplicateCounts, + localeCounts, + pagePer, + exporter, + store, + writeSemaphore, + null, + ct + ) + ) + .ToArray(); await Task.WhenAll(lanes); AnsiConsole.MarkupLine($"[green]✓[/] All {PageContentTypes.All.Length} content types synced"); } @@ -197,15 +232,20 @@ await AnsiConsole.Progress() await AnsiConsole.Status() .AutoRefresh(true) .Spinner(Spinner.Known.Dots) - .StartAsync("[aqua]Finalizing…[/]", async ctx => - { - exporter.OnSyncProgress = info => ctx.Status(SyncProgressConsole.FormatStatusMarkup(info)); - await exporter.FinalizeAsync(ct); - }); + .StartAsync( + "[aqua]Finalizing…[/]", + async ctx => + { + exporter.OnSyncProgress = info => ctx.Status(SyncProgressConsole.FormatStatusMarkup(info)); + await exporter.FinalizeAsync(ct); + } + ); } else { - AnsiConsole.MarkupLine("[aqua]Finalizing…[/] [dim](bulk ingest, rollover, reindex — progress below; flush details in logs)[/]"); + AnsiConsole.MarkupLine( + "[aqua]Finalizing…[/] [dim](bulk ingest, rollover, reindex — progress below; flush details in logs)[/]" + ); exporter.OnSyncProgress = info => { if (info.Total == 0 && info.Label.StartsWith("Flush", StringComparison.Ordinal)) @@ -218,7 +258,17 @@ await AnsiConsole.Status() } AnsiConsole.WriteLine(); - DisplaySummary(cursorMap, runCounts, indexedCounts, skippedCounts, duplicateCounts, localeCounts, exporter, noIndex, store.CacheFolder); + DisplaySummary( + cursorMap, + runCounts, + indexedCounts, + skippedCounts, + duplicateCounts, + localeCounts, + exporter, + noIndex, + store.CacheFolder + ); } finally { @@ -296,8 +346,7 @@ Cancel ct laneTask.MaxValue = p.TotalCount; laneTask.Value = p.ItemsSoFar; } - laneTask.Description = - $"[aqua]Lane {laneId}:[/] {Markup.Escape(contentType)} [dim]({p.ItemsSoFar:N0})[/]"; + laneTask.Description = $"[aqua]Lane {laneId}:[/] {Markup.Escape(contentType)} [dim]({p.ItemsSoFar:N0})[/]"; }); async Task OnPage(SyncResponse response) @@ -342,13 +391,7 @@ async Task OnPage(SyncResponse response) } var result = isDelta - ? await client.DeltaSyncAsync( - cursor.SyncToken!, - maxPages: maxPages, - progress: progress, - onPage: OnPage, - ct: ct - ) + ? await client.DeltaSyncAsync(cursor.SyncToken!, maxPages: maxPages, progress: progress, onPage: OnPage, ct: ct) : await client.InitialSyncAsync( resumePaginationToken: cursor.PaginationToken, contentTypeUid: cursor.PaginationToken == null ? contentType : null, @@ -382,8 +425,7 @@ async Task OnPage(SyncResponse response) if (overallTask is not null) { overallTask.Increment(1); - overallTask.Description = - $"[aqua]Overall[/] — [white]{(int)overallTask.Value}[/]/{PageContentTypes.All.Length} types"; + overallTask.Description = $"[aqua]Overall[/] — [white]{(int)overallTask.Value}[/]/{PageContentTypes.All.Length} types"; } if (laneTask is not null) @@ -393,7 +435,8 @@ async Task OnPage(SyncResponse response) var pct = totalThisType > 0 ? itemsThisType * 100 / totalThisType : 100; var skippedSuffix = skippedThisType > 0 ? $" [yellow]({skippedThisType:N0} skipped)[/]" : ""; AnsiConsole.MarkupLine( - $"[dim][[Lane {laneId}]][/]\t[dim]{pct,3}%[/]\t{Markup.Escape(contentType)} — {itemsThisType:N0} fetched, {indexedThisType:N0} indexed{skippedSuffix}"); + $"[dim][[Lane {laneId}]][/]\t[dim]{pct,3}%[/]\t{Markup.Escape(contentType)} — {itemsThisType:N0} fetched, {indexedThisType:N0} indexed{skippedSuffix}" + ); } } @@ -436,25 +479,20 @@ string cacheFolder var indexed = indexedCounts.GetValueOrDefault(contentType); var skipped = skippedCounts.GetValueOrDefault(contentType); - var status = cursor?.SyncToken != null - ? "[green]✓[/]" - : cursor?.PaginationToken != null - ? "[yellow]partial[/]" - : "[grey]—[/]"; + var status = cursor?.SyncToken != null ? "[green]✓[/]" : cursor?.PaginationToken != null ? "[yellow]partial[/]" : "[grey]—[/]"; var fetchedDisplay = fetched > 0 ? $"[green]+{fetched:N0}[/]" : "[dim]0[/]"; - var indexedDisplay = noIndex - ? "[dim]—[/]" - : indexed > 0 ? $"[green]{indexed:N0}[/]" : "[dim]0[/]"; + var indexedDisplay = noIndex ? "[dim]—[/]" : indexed > 0 ? $"[green]{indexed:N0}[/]" : "[dim]0[/]"; var skippedDisplay = skipped > 0 ? $"[yellow]{skipped:N0}[/]" : "[dim]0[/]"; - _ = table.AddRow( - new Markup(Markup.Escape(contentType)), - new Markup(fetchedDisplay), - new Markup(indexedDisplay), - new Markup(skippedDisplay), - new Markup(status) - ); + _ = + table.AddRow( + new Markup(Markup.Escape(contentType)), + new Markup(fetchedDisplay), + new Markup(indexedDisplay), + new Markup(skippedDisplay), + new Markup(status) + ); } AnsiConsole.Write(table); @@ -476,10 +514,7 @@ string cacheFolder AnsiConsole.WriteLine(); } - var summaryRows = new List - { - new($"[green]Fetched this run:[/] [white]{totalThisRun:N0}[/]"), - }; + var summaryRows = new List { new($"[green]Fetched this run:[/] [white]{totalThisRun:N0}[/]"), }; if (!noIndex) { @@ -494,7 +529,8 @@ string cacheFolder { var reindexMissed = exporter.ReindexTotal - exporter.ReindexProcessed; var reindexColor = reindexMissed > 0 || exporter.ReindexError is not null ? "yellow" : "green"; - var reindexLine = $"[{reindexColor}]Reindexed to semantic:[/] [white]{exporter.ReindexProcessed:N0}/{exporter.ReindexTotal:N0}[/]"; + var reindexLine = + $"[{reindexColor}]Reindexed to semantic:[/] [white]{exporter.ReindexProcessed:N0}/{exporter.ReindexTotal:N0}[/]"; if (reindexMissed > 0) reindexLine += $" [yellow]({reindexMissed:N0} missed)[/]"; if (exporter.ReindexVersionConflicts > 0) diff --git a/src/tooling/essc/ContentStack/ContentStackClient.cs b/src/tooling/essc/ContentStack/ContentStackClient.cs index 86a541fdca..ea9fff6ed8 100644 --- a/src/tooling/essc/ContentStack/ContentStackClient.cs +++ b/src/tooling/essc/ContentStack/ContentStackClient.cs @@ -7,11 +7,7 @@ namespace Elastic.SiteSearch.Cli.ContentStack; -internal sealed class ContentStackClient( - HttpClient httpClient, - ContentStackConfiguration configuration, - ILogger logger -) +internal sealed class ContentStackClient(HttpClient httpClient, ContentStackConfiguration configuration, ILogger logger) { private const int MaxBodyRetries = 5; @@ -85,9 +81,7 @@ Cancel ct progress?.Report(new SyncProgress(page, allItems.Count, response.TotalCount)); - logger.LogDebug( - "Page {Page}: received {Count} items (total so far: {Total})", - page, response.Items.Count, allItems.Count); + logger.LogDebug("Page {Page}: received {Count} items (total so far: {Total})", page, response.Items.Count, allItems.Count); if (onPage != null) await onPage(response); @@ -119,15 +113,18 @@ private async Task FetchPageAsync(string url, Cancel ct) try { var response = await httpClient.GetFromJsonAsync(url, SyncJsonContext.Default.SyncResponse, ct); - return response - ?? throw new InvalidOperationException($"Received null response from Contentstack sync API at {url}"); + return response ?? throw new InvalidOperationException($"Received null response from Contentstack sync API at {url}"); } catch (HttpIOException ex) when (attempt < MaxBodyRetries) { var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); logger.LogWarning( "Response body truncated (attempt {Attempt}/{Max}), retrying in {Delay}s: {Message}", - attempt + 1, MaxBodyRetries, delay.TotalSeconds, ex.Message); + attempt + 1, + MaxBodyRetries, + delay.TotalSeconds, + ex.Message + ); await Task.Delay(delay, ct); } } diff --git a/src/tooling/essc/ContentStack/ContentStackConfiguration.cs b/src/tooling/essc/ContentStack/ContentStackConfiguration.cs index e14b793325..9282a024fb 100644 --- a/src/tooling/essc/ContentStack/ContentStackConfiguration.cs +++ b/src/tooling/essc/ContentStack/ContentStackConfiguration.cs @@ -15,10 +15,7 @@ internal sealed class ContentStackConfiguration public static ContentStackConfiguration CreateFromEnvironment() { - var config = new ConfigurationBuilder() - .AddUserSecrets("docs-builder") - .AddEnvironmentVariables() - .Build(); + var config = new ConfigurationBuilder().AddUserSecrets("docs-builder").AddEnvironmentVariables().Build(); return CreateFromConfiguration(config); } @@ -29,20 +26,14 @@ public static ContentStackConfiguration CreateFromEnvironment() /// public static ContentStackConfiguration? TryCreateFromConfiguration(IConfiguration config) { - var apiKey = config["ContentStack:ApiKey"] - ?? config["CONTENTSTACK_API_KEY"]; + var apiKey = config["ContentStack:ApiKey"] ?? config["CONTENTSTACK_API_KEY"]; - var deliveryToken = config["ContentStack:DeliveryToken"] - ?? config["CONTENTSTACK_DELIVERY_TOKEN"]; + var deliveryToken = config["ContentStack:DeliveryToken"] ?? config["CONTENTSTACK_DELIVERY_TOKEN"]; if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(deliveryToken)) return null; - return new ContentStackConfiguration - { - ApiKey = apiKey, - DeliveryToken = deliveryToken - }; + return new ContentStackConfiguration { ApiKey = apiKey, DeliveryToken = deliveryToken }; } public static ContentStackConfiguration CreateFromConfiguration(IConfiguration config) @@ -55,10 +46,12 @@ public static ContentStackConfiguration CreateFromConfiguration(IConfiguration c if (string.IsNullOrWhiteSpace(apiKey)) throw new InvalidOperationException( "Contentstack API key not found. Set 'ContentStack:ApiKey' via dotnet user-secrets " + - "or the CONTENTSTACK_API_KEY environment variable."); + "or the CONTENTSTACK_API_KEY environment variable." + ); throw new InvalidOperationException( "Contentstack delivery token not found. Set 'ContentStack:DeliveryToken' via dotnet user-secrets " + - "or the CONTENTSTACK_DELIVERY_TOKEN environment variable."); + "or the CONTENTSTACK_DELIVERY_TOKEN environment variable." + ); } } diff --git a/src/tooling/essc/ContentStack/ContentStackMapper.cs b/src/tooling/essc/ContentStack/ContentStackMapper.cs index 4a99be34e3..0a8e059617 100644 --- a/src/tooling/essc/ContentStack/ContentStackMapper.cs +++ b/src/tooling/essc/ContentStack/ContentStackMapper.cs @@ -76,11 +76,7 @@ internal static partial class ContentStackMapper Translated = true, // navigation.depth/navigation.table_of_contents are rank features with positive_score_impact:false, // designed for hierarchical docs: lower values score higher. - Navigation = new NavigationMetrics - { - Depth = ComputeNavigationDepth(url) + 1, - TableOfContents = 100 - }, + Navigation = new NavigationMetrics { Depth = ComputeNavigationDepth(url) + 1, TableOfContents = 100 }, Locale = language, PublishedDate = publishedDate, ModifiedDate = modifiedDate, @@ -287,9 +283,7 @@ private static string ResolveUrlForLocale(string url, string? locale) // site-served prefixes are always two letters, never the full locale code — so every // non-English variant still gets its own document id, or it silently collides with (and // can 409 against) the entry for another locale of the same underlying ContentStack url. - var prefix = LocaleUrlPrefixes.TryGetValue(locale, out var mapped) - ? mapped - : locale.Split('-')[0].ToLowerInvariant(); + var prefix = LocaleUrlPrefixes.TryGetValue(locale, out var mapped) ? mapped : locale.Split('-')[0].ToLowerInvariant(); return $"/{prefix}{url}"; } @@ -325,9 +319,7 @@ private static bool TryGetLanguageFromUrlPrefix(string url, out string language) private static int ComputeNavigationDepth(string url) { - var path = url.StartsWith("http", StringComparison.OrdinalIgnoreCase) - ? new Uri(url).AbsolutePath - : url; + var path = url.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? new Uri(url).AbsolutePath : url; return path.Split('/', StringSplitOptions.RemoveEmptyEntries).Length; } @@ -439,10 +431,7 @@ internal static string StripHtml(string html) internal static string[] ExtractHeadings(string html) { var matches = HeadingRegex().Matches(html); - return matches - .Select(m => StripHtml(m.Groups[1].Value)) - .Where(h => !string.IsNullOrWhiteSpace(h)) - .ToArray(); + return matches.Select(m => StripHtml(m.Groups[1].Value)).Where(h => !string.IsNullOrWhiteSpace(h)).ToArray(); } private static string ComputeHash(string content) diff --git a/src/tooling/essc/ContentStack/RateLimitingHandler.cs b/src/tooling/essc/ContentStack/RateLimitingHandler.cs index 0088365b1d..837634218b 100644 --- a/src/tooling/essc/ContentStack/RateLimitingHandler.cs +++ b/src/tooling/essc/ContentStack/RateLimitingHandler.cs @@ -13,10 +13,7 @@ namespace Elastic.SiteSearch.Cli.ContentStack; /// internal sealed class RateLimitingHandler(RateLimiter limiter) : DelegatingHandler { - protected override async Task SendAsync( - HttpRequestMessage request, - CancellationToken ct - ) + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) { using var lease = await limiter.AcquireAsync(1, ct); if (!lease.IsAcquired) diff --git a/src/tooling/essc/ContentStack/SourcingState.cs b/src/tooling/essc/ContentStack/SourcingState.cs index e155ca4eca..47b9b75a99 100644 --- a/src/tooling/essc/ContentStack/SourcingState.cs +++ b/src/tooling/essc/ContentStack/SourcingState.cs @@ -49,9 +49,11 @@ public void Ingest(SyncItem item) if (item.Data is not { } data) return; - if (!data.TryGetProperty("url", out var urlProp) + if ( + !data.TryGetProperty("url", out var urlProp) || urlProp.ValueKind != JsonValueKind.String - || string.IsNullOrWhiteSpace(urlProp.GetString())) + || string.IsNullOrWhiteSpace(urlProp.GetString()) + ) return; WithUrl++; diff --git a/src/tooling/essc/ContentStack/StateManager.cs b/src/tooling/essc/ContentStack/StateManager.cs index d127261829..25b54a6b73 100644 --- a/src/tooling/essc/ContentStack/StateManager.cs +++ b/src/tooling/essc/ContentStack/StateManager.cs @@ -15,10 +15,8 @@ internal sealed class StateManager public StateManager(string? cacheFolderOverride = null) { - CacheFolder = cacheFolderOverride - ?? Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - AppName); + CacheFolder = + cacheFolderOverride ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), AppName); _ = Directory.CreateDirectory(CacheFolder); } diff --git a/src/tooling/essc/Elasticsearch/ContentTierClassifier.cs b/src/tooling/essc/Elasticsearch/ContentTierClassifier.cs index bfa4db27a3..1bdf5fb9e1 100644 --- a/src/tooling/essc/Elasticsearch/ContentTierClassifier.cs +++ b/src/tooling/essc/Elasticsearch/ContentTierClassifier.cs @@ -19,14 +19,11 @@ internal static class ContentTierClassifier { // Editorial overviews and flagship product pages. "concept" or "product" => ContentTiers.Primary, - // Marketing, legal, and low-signal pages — demoted. "marketing" or "legal" or "download" or "press" or "pricing" => ContentTiers.Peripheral, - // Useful but secondary content. - "webinar" or "event" or "customer-story" or "demo" or "about" - or "training" or "resource" or "industry" or "partner" => ContentTiers.Supplementary, - + "webinar" or "event" or "customer-story" or "demo" or "about" or "training" or "resource" or "industry" or "partner" => + ContentTiers.Supplementary, // Blog, labs sub-sections, and anything unrecognised default to the neutral tier. _ => ContentTiers.Reference, }; diff --git a/src/tooling/essc/Elasticsearch/ElasticsearchTransportFactory.cs b/src/tooling/essc/Elasticsearch/ElasticsearchTransportFactory.cs index 6c6ef4646d..d423bbffe2 100644 --- a/src/tooling/essc/Elasticsearch/ElasticsearchTransportFactory.cs +++ b/src/tooling/essc/Elasticsearch/ElasticsearchTransportFactory.cs @@ -15,15 +15,11 @@ public static DistributedTransport Create(ElasticsearchEndpoint endpoint) { Authentication = endpoint.ApiKey is { } apiKey ? new ApiKey(apiKey) - : endpoint is { Username: { } username, Password: { } password } - ? new BasicAuthentication(username, password) - : null, + : endpoint is { Username: { } username, Password: { } password } ? new BasicAuthentication(username, password) : null, EnableHttpCompression = true, DebugMode = endpoint.DebugMode, CertificateFingerprint = endpoint.CertificateFingerprint, - ServerCertificateValidationCallback = endpoint.DisableSslVerification - ? CertificateValidations.AllowAll - : null + ServerCertificateValidationCallback = endpoint.DisableSslVerification ? CertificateValidations.AllowAll : null }; return new DistributedTransport(configuration); diff --git a/src/tooling/essc/Elasticsearch/LabsDocumentExporter.cs b/src/tooling/essc/Elasticsearch/LabsDocumentExporter.cs index c491c1c543..61d511e037 100644 --- a/src/tooling/essc/Elasticsearch/LabsDocumentExporter.cs +++ b/src/tooling/essc/Elasticsearch/LabsDocumentExporter.cs @@ -68,13 +68,11 @@ bool enableAiEnrichment var synonymSetName = $"docs-assembler-{environment}"; var indexTimeSynonyms = IndexTimeSynonyms.Docs; - var lexicalContext = LabsMappingContext.LabsDocument - .CreateContext(type: buildType, env: environment) with + var lexicalContext = LabsMappingContext.LabsDocument.CreateContext(type: buildType, env: environment) with { ConfigureAnalysis = a => SharedAnalysisFactory.BuildAnalysis(a, synonymSetName, indexTimeSynonyms) }; - var semanticContext = LabsMappingContext.LabsDocumentSemantic - .CreateContext(type: buildType, env: environment) with + var semanticContext = LabsMappingContext.LabsDocumentSemantic.CreateContext(type: buildType, env: environment) with { ConfigureAnalysis = a => SharedAnalysisFactory.BuildAnalysis(a, synonymSetName, indexTimeSynonyms) }; @@ -86,7 +84,10 @@ bool enableAiEnrichment var infra = provider.CreateInfrastructure($"{semanticContext.IndexStrategy!.WriteTarget}-ai-cache"); _logger.LogInformation( "AI enrichment enabled — pipeline: {Pipeline}, policy: {Policy}, lookup: {Lookup}", - infra.PipelineName, infra.EnrichPolicyName, infra.LookupIndexName); + infra.PipelineName, + infra.EnrichPolicyName, + infra.LookupIndexName + ); semanticContext = semanticContext with { @@ -105,7 +106,11 @@ bool enableAiEnrichment var decision = info.RolledOver ? "NEW" : "EXISTING"; _logger.LogInformation( "[{Label}] bootstrap decision: targeting {Decision} index (localHash={LocalHash}, remoteHash={RemoteHash})", - info.Label, decision, info.LocalHash, info.RemoteHash); + info.Label, + decision, + info.LocalHash, + info.RemoteHash + ); if (info.Label == "primary") _primaryRolledOver = info.RolledOver; else @@ -117,7 +122,15 @@ bool enableAiEnrichment { _logger.LogInformation( "[{Label}] total={Total} created={Created} updated={Updated} deleted={Deleted} noops={Noops} versionConflicts={VersionConflicts} completed={IsCompleted}", - label, p.Total, p.Created, p.Updated, p.Deleted, p.Noops, p.VersionConflicts, p.IsCompleted); + label, + p.Total, + p.Created, + p.Updated, + p.Deleted, + p.Noops, + p.VersionConflicts, + p.IsCompleted + ); if (p.Error is { } err) _logger.LogError("[{Label}] reindex error: {Error}", label, err); var processed = p.Created + p.Updated + p.Deleted + p.Noops; @@ -132,23 +145,28 @@ bool enableAiEnrichment { _logger.LogInformation( "[{Label}] total={Total} deleted={Deleted} completed={IsCompleted}", - label, p.Total, p.Deleted, p.IsCompleted); + label, + p.Total, + p.Deleted, + p.IsCompleted + ); OnSyncProgress?.Invoke(new SyncProgressInfo($"Delete by query — {label}", p.Total, p.Deleted, p.IsCompleted)); }, OnPostComplete = _aiEnrichment is not null ? OnPostCompleteAiAsync : null }; var validator = new SearchResourceValidator(transport, _logger); - _ = _orchestrator.AddPreBootstrapTask(async (_, ct) => - { - await validator.ValidateAsync(environment, ct); - if (_aiEnrichment is not null) + _ = + _orchestrator.AddPreBootstrapTask(async (_, ct) => { - _logger.LogInformation("Initializing AI enrichment infrastructure..."); - await _aiEnrichment.InitializeAsync(ct); - _logger.LogInformation("AI enrichment infrastructure ready"); - } - }); + await validator.ValidateAsync(environment, ct); + if (_aiEnrichment is not null) + { + _logger.LogInformation("Initializing AI enrichment infrastructure..."); + await _aiEnrichment.InitializeAsync(ct); + _logger.LogInformation("AI enrichment infrastructure ready"); + } + }); } private Task OnPostCompleteAiAsync(OrchestratorContext context, ITransport _, CancellationToken ct) => @@ -158,7 +176,8 @@ private Task OnPostCompleteAiAsync(OrchestratorContext context, IT _postSyncAiBudget, _logger, ct, - p => OnSyncProgress?.Invoke(SyncProgressConsole.FromAiProgress(p))); + p => OnSyncProgress?.Invoke(SyncProgressConsole.FromAiProgress(p)) + ); private void ConfigureChannelOptions( string label, @@ -180,8 +199,7 @@ private void ConfigureChannelOptions( { if (response.Items is null) { - _logger.LogWarning("[{Label}] export response had no items: {DebugInfo}", - label, response.ApiCallDetails.DebugInformation); + _logger.LogWarning("[{Label}] export response had no items: {DebugInfo}", label, response.ApiCallDetails.DebugInformation); return; } var sent = response.Items.Count; @@ -189,8 +207,7 @@ private void ConfigureChannelOptions( var indexed = label == "primary" ? Interlocked.Add(ref _primaryIndexed, sent - errors) : Interlocked.Add(ref _secondaryIndexed, sent - errors); - _logger.LogInformation("[{Label}] indexed {Indexed} items. {Errors} errors. sent: {Sent} items", - label, indexed, errors, sent); + _logger.LogInformation("[{Label}] indexed {Indexed} items. {Errors} errors. sent: {Sent} items", label, indexed, errors, sent); if (_isFinalizing) OnSyncProgress?.Invoke(new SyncProgressInfo($"Flush — {label}", Total: 0, indexed, IsComplete: false)); if (!response.ApiCallDetails.HasSuccessfulStatusCode) @@ -211,8 +228,14 @@ private void ConfigureChannelOptions( _ = Interlocked.Add(ref _rejectedCount, items.Count); foreach (var (doc, responseItem) in items) { - _logger.LogError("[{Label}] Server rejection: {Status} {Type} {Reason} for {Path}", - label, responseItem.Status, responseItem.Error?.Type, responseItem.Error?.Reason, doc.Path); + _logger.LogError( + "[{Label}] Server rejection: {Status} {Type} {Reason} for {Path}", + label, + responseItem.Status, + responseItem.Error?.Type, + responseItem.Error?.Reason, + doc.Path + ); } }; } @@ -225,13 +248,19 @@ public async ValueTask StartAsync(Cancel ctx = default) var primaryIndex = await IndexResolution.ResolveConcreteIndexAsync(_transport, context.PrimaryWriteAlias, ctx); PrimaryBootstrap = new IndexBootstrapInfo(context.PrimaryWriteAlias, primaryIndex, _primaryRolledOver); - _logger.LogInformation("[primary] target index: {Index} (alias: {Alias})", - primaryIndex ?? "", context.PrimaryWriteAlias); + _logger.LogInformation( + "[primary] target index: {Index} (alias: {Alias})", + primaryIndex ?? "", + context.PrimaryWriteAlias + ); var secondaryIndex = await IndexResolution.ResolveConcreteIndexAsync(_transport, context.SecondaryWriteAlias, ctx); SecondaryBootstrap = new IndexBootstrapInfo(context.SecondaryWriteAlias, secondaryIndex, _secondaryRolledOver); - _logger.LogInformation("[secondary] target index: {Index} (alias: {Alias})", - secondaryIndex ?? "", context.SecondaryWriteAlias); + _logger.LogInformation( + "[secondary] target index: {Index} (alias: {Alias})", + secondaryIndex ?? "", + context.SecondaryWriteAlias + ); } public async Task ExportAsync(LabsDocument document, Cancel ct = default) @@ -241,7 +270,6 @@ public async Task ExportAsync(LabsDocument document, Cancel ct = default) _ = await _orchestrator.WaitToWriteAsync(document, ct); } - public async ValueTask FinalizeAsync(Cancel ctx = default) { _logger.LogInformation("Finalizing indexing..."); @@ -271,4 +299,3 @@ public void Dispose() GC.SuppressFinalize(this); } } - diff --git a/src/tooling/essc/Elasticsearch/SearchResourceSynchronizer.cs b/src/tooling/essc/Elasticsearch/SearchResourceSynchronizer.cs index 0d55ab8bfc..28a6707537 100644 --- a/src/tooling/essc/Elasticsearch/SearchResourceSynchronizer.cs +++ b/src/tooling/essc/Elasticsearch/SearchResourceSynchronizer.cs @@ -33,10 +33,7 @@ namespace Elastic.SiteSearch.Cli.Elasticsearch; /// query-rule entries are compared in-order. /// /// -internal sealed partial class SearchResourceSynchronizer( - DistributedTransport source, - DistributedTransport destination, - ILogger logger) +internal sealed partial class SearchResourceSynchronizer(DistributedTransport source, DistributedTransport destination, ILogger logger) { /// /// Tries to extract the environment token from a known alias name. @@ -95,13 +92,14 @@ private async Task CopySynonymSetAsync(string name, CancellationToken ct) { throw new InvalidOperationException( $"Synonym set '{name}' not found on source cluster. " + - "Ensure docs-builder indexing has run for this environment before syncing."); + "Ensure docs-builder indexing has run for this environment before syncing." + ); } // Extract the synonyms_set array var srcRoot = JsonNode.Parse(srcResp.Body ?? "{}"); - var srcSet = srcRoot?["synonyms_set"]?.AsArray() - ?? throw new InvalidOperationException($"Synonym set '{name}': unexpected response shape — 'synonyms_set' missing."); + var srcSet = srcRoot?["synonyms_set"]?.AsArray() ?? + throw new InvalidOperationException($"Synonym set '{name}': unexpected response shape — 'synonyms_set' missing."); // ── No-op check ──────────────────────────────────────────────────────────── var dstResp = await destination.GetAsync($"_synonyms/{name}", cancellationToken: ct); @@ -119,13 +117,13 @@ private async Task CopySynonymSetAsync(string name, CancellationToken ct) // ── Copy to destination ──────────────────────────────────────────────────── // Rebuild the body with only the array (strip result_count etc.) var putBody = $"{{\"synonyms_set\":{srcSet.ToJsonString()}}}"; - var putResp = await destination.PutAsync( - $"_synonyms/{name}", PostData.String(putBody), ct); + var putResp = await destination.PutAsync($"_synonyms/{name}", PostData.String(putBody), ct); if (!putResp.ApiCallDetails.HasSuccessfulStatusCode) { throw new InvalidOperationException( $"Failed to copy synonym set '{name}' to destination: " + - $"{putResp.ApiCallDetails.OriginalException?.Message ?? putResp.ToString()}"); + $"{putResp.ApiCallDetails.OriginalException?.Message ?? putResp.ToString()}" + ); } logger.LogInformation("Synonym set '{Name}' copied to destination", name); @@ -139,13 +137,14 @@ private async Task CopyQueryRulesetAsync(string name, CancellationToken ct) { throw new InvalidOperationException( $"Query ruleset '{name}' not found on source cluster. " + - "Ensure docs-builder indexing has run for this environment before syncing."); + "Ensure docs-builder indexing has run for this environment before syncing." + ); } // Extract the rules array (ordering is significant — no sort) var srcRoot = JsonNode.Parse(srcResp.Body ?? "{}"); - var srcRules = srcRoot?["rules"]?.AsArray() - ?? throw new InvalidOperationException($"Query ruleset '{name}': unexpected response shape — 'rules' missing."); + var srcRules = srcRoot?["rules"]?.AsArray() ?? + throw new InvalidOperationException($"Query ruleset '{name}': unexpected response shape — 'rules' missing."); // ── No-op check ──────────────────────────────────────────────────────────── var dstResp = await destination.GetAsync($"_query_rules/{name}", cancellationToken: ct); @@ -153,8 +152,7 @@ private async Task CopyQueryRulesetAsync(string name, CancellationToken ct) { var dstRoot = JsonNode.Parse(dstResp.Body ?? "{}"); var dstRules = dstRoot?["rules"]?.AsArray(); - if (dstRules is not null && - srcRules.ToJsonString() == dstRules.ToJsonString()) + if (dstRules is not null && srcRules.ToJsonString() == dstRules.ToJsonString()) { logger.LogInformation("Query ruleset '{Name}' is already up-to-date on destination — skipping PUT", name); return; @@ -163,13 +161,13 @@ private async Task CopyQueryRulesetAsync(string name, CancellationToken ct) // ── Copy to destination ──────────────────────────────────────────────────── var putBody = $"{{\"rules\":{srcRules.ToJsonString()}}}"; - var putResp = await destination.PutAsync( - $"_query_rules/{name}", PostData.String(putBody), ct); + var putResp = await destination.PutAsync($"_query_rules/{name}", PostData.String(putBody), ct); if (!putResp.ApiCallDetails.HasSuccessfulStatusCode) { throw new InvalidOperationException( $"Failed to copy query ruleset '{name}' to destination: " + - $"{putResp.ApiCallDetails.OriginalException?.Message ?? putResp.ToString()}"); + $"{putResp.ApiCallDetails.OriginalException?.Message ?? putResp.ToString()}" + ); } logger.LogInformation("Query ruleset '{Name}' copied to destination", name); diff --git a/src/tooling/essc/Elasticsearch/SearchResourceValidator.cs b/src/tooling/essc/Elasticsearch/SearchResourceValidator.cs index 6231a4f5ff..d41f164397 100644 --- a/src/tooling/essc/Elasticsearch/SearchResourceValidator.cs +++ b/src/tooling/essc/Elasticsearch/SearchResourceValidator.cs @@ -27,7 +27,8 @@ private async Task ValidateSynonymSetAsync(string setName, CancellationToken ct) { throw new InvalidOperationException( $"Synonym set '{setName}' not found on {transport}. " + - $"Run docs-builder indexing for this environment first to publish the required synonym set."); + $"Run docs-builder indexing for this environment first to publish the required synonym set." + ); } logger.LogInformation("Synonym set '{SetName}' validated", setName); } @@ -43,13 +44,15 @@ private async Task ValidateQueryRulesetAsync(string rulesetName, CancellationTok // log a warning rather than hard-failing. logger.LogWarning( "Query ruleset '{RulesetName}' not found — query rules will not apply. " + - "Run docs-builder indexing for this environment to publish query rules.", - rulesetName); + "Run docs-builder indexing for this environment to publish query rules.", + rulesetName + ); return; } throw new InvalidOperationException( - $"Failed to check query ruleset '{rulesetName}': {response.ApiCallDetails.OriginalException?.Message ?? response.ToString()}"); + $"Failed to check query ruleset '{rulesetName}': {response.ApiCallDetails.OriginalException?.Message ?? response.ToString()}" + ); } logger.LogInformation("Query ruleset '{RulesetName}' validated", rulesetName); } diff --git a/src/tooling/essc/Elasticsearch/SiteDocumentExporter.cs b/src/tooling/essc/Elasticsearch/SiteDocumentExporter.cs index 3213a1983f..d1a9a8a18f 100644 --- a/src/tooling/essc/Elasticsearch/SiteDocumentExporter.cs +++ b/src/tooling/essc/Elasticsearch/SiteDocumentExporter.cs @@ -69,13 +69,11 @@ bool enableAiEnrichment var synonymSetName = $"docs-assembler-{environment}"; var indexTimeSynonyms = IndexTimeSynonyms.Docs; - var lexicalContext = SiteMappingContext.SiteDocument - .CreateContext(type: buildType, env: environment) with + var lexicalContext = SiteMappingContext.SiteDocument.CreateContext(type: buildType, env: environment) with { ConfigureAnalysis = a => SharedAnalysisFactory.BuildAnalysis(a, synonymSetName, indexTimeSynonyms) }; - var semanticContext = SiteMappingContext.SiteDocumentSemantic - .CreateContext(type: buildType, env: environment) with + var semanticContext = SiteMappingContext.SiteDocumentSemantic.CreateContext(type: buildType, env: environment) with { ConfigureAnalysis = a => SharedAnalysisFactory.BuildAnalysis(a, synonymSetName, indexTimeSynonyms) }; @@ -87,7 +85,10 @@ bool enableAiEnrichment var infra = provider.CreateInfrastructure($"{semanticContext.IndexStrategy!.WriteTarget}-ai-cache"); _logger.LogInformation( "AI enrichment enabled — pipeline: {Pipeline}, policy: {Policy}, lookup: {Lookup}", - infra.PipelineName, infra.EnrichPolicyName, infra.LookupIndexName); + infra.PipelineName, + infra.EnrichPolicyName, + infra.LookupIndexName + ); semanticContext = semanticContext with { @@ -106,7 +107,11 @@ bool enableAiEnrichment var decision = info.RolledOver ? "NEW" : "EXISTING"; _logger.LogInformation( "[{Label}] bootstrap decision: targeting {Decision} index (localHash={LocalHash}, remoteHash={RemoteHash})", - info.Label, decision, info.LocalHash, info.RemoteHash); + info.Label, + decision, + info.LocalHash, + info.RemoteHash + ); if (info.Label == "primary") _primaryRolledOver = info.RolledOver; else @@ -118,7 +123,15 @@ bool enableAiEnrichment { _logger.LogInformation( "[{Label}] total={Total} created={Created} updated={Updated} deleted={Deleted} noops={Noops} versionConflicts={VersionConflicts} completed={IsCompleted}", - label, p.Total, p.Created, p.Updated, p.Deleted, p.Noops, p.VersionConflicts, p.IsCompleted); + label, + p.Total, + p.Created, + p.Updated, + p.Deleted, + p.Noops, + p.VersionConflicts, + p.IsCompleted + ); if (p.Error is { } err) _logger.LogError("[{Label}] reindex error: {Error}", label, err); var processed = p.Created + p.Updated + p.Deleted + p.Noops; @@ -133,23 +146,28 @@ bool enableAiEnrichment { _logger.LogInformation( "[{Label}] total={Total} deleted={Deleted} completed={IsCompleted}", - label, p.Total, p.Deleted, p.IsCompleted); + label, + p.Total, + p.Deleted, + p.IsCompleted + ); OnSyncProgress?.Invoke(new SyncProgressInfo($"Delete by query — {label}", p.Total, p.Deleted, p.IsCompleted)); }, OnPostComplete = _aiEnrichment is not null ? OnPostCompleteAiAsync : null }; var validator = new SearchResourceValidator(transport, _logger); - _ = _orchestrator.AddPreBootstrapTask(async (_, ct) => - { - await validator.ValidateAsync(environment, ct); - if (_aiEnrichment is not null) + _ = + _orchestrator.AddPreBootstrapTask(async (_, ct) => { - _logger.LogInformation("Initializing AI enrichment infrastructure..."); - await _aiEnrichment.InitializeAsync(ct); - _logger.LogInformation("AI enrichment infrastructure ready"); - } - }); + await validator.ValidateAsync(environment, ct); + if (_aiEnrichment is not null) + { + _logger.LogInformation("Initializing AI enrichment infrastructure..."); + await _aiEnrichment.InitializeAsync(ct); + _logger.LogInformation("AI enrichment infrastructure ready"); + } + }); } private Task OnPostCompleteAiAsync(OrchestratorContext context, ITransport _, CancellationToken ct) => @@ -159,7 +177,8 @@ private Task OnPostCompleteAiAsync(OrchestratorContext context, IT _postSyncAiBudget, _logger, ct, - p => OnSyncProgress?.Invoke(SyncProgressConsole.FromAiProgress(p))); + p => OnSyncProgress?.Invoke(SyncProgressConsole.FromAiProgress(p)) + ); private void ConfigureChannelOptions( string label, @@ -181,8 +200,7 @@ private void ConfigureChannelOptions( { if (response.Items is null) { - _logger.LogWarning("[{Label}] export response had no items: {DebugInfo}", - label, response.ApiCallDetails.DebugInformation); + _logger.LogWarning("[{Label}] export response had no items: {DebugInfo}", label, response.ApiCallDetails.DebugInformation); return; } var sent = response.Items.Count; @@ -190,8 +208,7 @@ private void ConfigureChannelOptions( var indexed = label == "primary" ? Interlocked.Add(ref _primaryIndexed, sent - errors) : Interlocked.Add(ref _secondaryIndexed, sent - errors); - _logger.LogInformation("[{Label}] indexed {Indexed} items. {Errors} errors. sent: {Sent} items", - label, indexed, errors, sent); + _logger.LogInformation("[{Label}] indexed {Indexed} items. {Errors} errors. sent: {Sent} items", label, indexed, errors, sent); if (_isFinalizing) OnSyncProgress?.Invoke(new SyncProgressInfo($"Flush — {label}", Total: 0, indexed, IsComplete: false)); if (!response.ApiCallDetails.HasSuccessfulStatusCode) @@ -212,8 +229,14 @@ private void ConfigureChannelOptions( _ = Interlocked.Add(ref _rejectedCount, items.Count); foreach (var (doc, responseItem) in items) { - _logger.LogError("[{Label}] Server rejection: {Status} {Type} {Reason} for {Path}", - label, responseItem.Status, responseItem.Error?.Type, responseItem.Error?.Reason, doc.Path); + _logger.LogError( + "[{Label}] Server rejection: {Status} {Type} {Reason} for {Path}", + label, + responseItem.Status, + responseItem.Error?.Type, + responseItem.Error?.Reason, + doc.Path + ); } }; } @@ -226,13 +249,19 @@ public async ValueTask StartAsync(Cancel ctx = default) var primaryIndex = await IndexResolution.ResolveConcreteIndexAsync(_transport, context.PrimaryWriteAlias, ctx); PrimaryBootstrap = new IndexBootstrapInfo(context.PrimaryWriteAlias, primaryIndex, _primaryRolledOver); - _logger.LogInformation("[primary] target index: {Index} (alias: {Alias})", - primaryIndex ?? "", context.PrimaryWriteAlias); + _logger.LogInformation( + "[primary] target index: {Index} (alias: {Alias})", + primaryIndex ?? "", + context.PrimaryWriteAlias + ); var secondaryIndex = await IndexResolution.ResolveConcreteIndexAsync(_transport, context.SecondaryWriteAlias, ctx); SecondaryBootstrap = new IndexBootstrapInfo(context.SecondaryWriteAlias, secondaryIndex, _secondaryRolledOver); - _logger.LogInformation("[secondary] target index: {Index} (alias: {Alias})", - secondaryIndex ?? "", context.SecondaryWriteAlias); + _logger.LogInformation( + "[secondary] target index: {Index} (alias: {Alias})", + secondaryIndex ?? "", + context.SecondaryWriteAlias + ); } public async Task ExportAsync(SiteDocument document, Cancel ct = default) @@ -242,7 +271,6 @@ public async Task ExportAsync(SiteDocument document, Cancel ct = default) _ = await _orchestrator.WaitToWriteAsync(document, ct); } - public async ValueTask FinalizeAsync(Cancel ctx = default) { _logger.LogInformation("Finalizing indexing..."); diff --git a/src/tooling/essc/Elasticsearch/SourcingConfiguration.cs b/src/tooling/essc/Elasticsearch/SourcingConfiguration.cs index 95f2764bbf..1c75d2fb5f 100644 --- a/src/tooling/essc/Elasticsearch/SourcingConfiguration.cs +++ b/src/tooling/essc/Elasticsearch/SourcingConfiguration.cs @@ -24,10 +24,7 @@ public ContentStackConfiguration RequireContentStack() if (ContentStack is not null) return ContentStack; - var config = new ConfigurationBuilder() - .AddUserSecrets("docs-builder") - .AddEnvironmentVariables() - .Build(); + var config = new ConfigurationBuilder().AddUserSecrets("docs-builder").AddEnvironmentVariables().Build(); return ContentStackConfiguration.CreateFromConfiguration(config); } public required ElasticsearchEndpoint Elasticsearch { get; init; } @@ -44,15 +41,9 @@ public ContentStackConfiguration RequireContentStack() public string ElasticsearchEnvironment { get; init; } = "dev"; public string BuildType { get; init; } = "public"; - public static SourcingConfiguration CreateFromEnvironment( - string? esUrl = null, - string? esApiKey = null - ) + public static SourcingConfiguration CreateFromEnvironment(string? esUrl = null, string? esApiKey = null) { - var config = new ConfigurationBuilder() - .AddUserSecrets("docs-builder") - .AddEnvironmentVariables() - .Build(); + var config = new ConfigurationBuilder().AddUserSecrets("docs-builder").AddEnvironmentVariables().Build(); var csConfig = ContentStackConfiguration.TryCreateFromConfiguration(config); diff --git a/src/tooling/essc/Elasticsearch/SyncProgressConsole.cs b/src/tooling/essc/Elasticsearch/SyncProgressConsole.cs index f59fe20c02..3b7309dbf6 100644 --- a/src/tooling/essc/Elasticsearch/SyncProgressConsole.cs +++ b/src/tooling/essc/Elasticsearch/SyncProgressConsole.cs @@ -28,8 +28,7 @@ public static string FormatStatusMarkup(SyncProgressInfo info) { var pct = (int)(info.Processed * 100 / Math.Max(1, info.Total)); var prefix = info.IsComplete ? "[green]✓ [/]" : ""; - return - $"{prefix}[aqua]{label}[/] [dim]—[/] [dim]{pct}%[/] [white]{info.Processed:N0}[/]/[dim]{info.Total:N0}[/]"; + return $"{prefix}[aqua]{label}[/] [dim]—[/] [dim]{pct}%[/] [white]{info.Processed:N0}[/]/[dim]{info.Total:N0}[/]"; } if (info.Processed > 0) @@ -62,28 +61,14 @@ public static SyncProgressInfo FromAiProgress(AiEnrichmentProgress p) return p.Phase switch { - AiEnrichmentPhase.Querying => new SyncProgressInfo( - $"AI enrichment — querying{msg}", - totalCandidates, - 0, - false), - AiEnrichmentPhase.Enriching => new SyncProgressInfo( - $"AI enrichment — enriching{msg}", - Math.Max(1, totalCandidates), - done, - false), - AiEnrichmentPhase.Complete => totalCandidates > 0 - ? new SyncProgressInfo( - $"AI enrichment — complete{msg}", - totalCandidates, - Math.Min(done, totalCandidates), - true) - : new SyncProgressInfo($"AI enrichment — complete{msg}", 1, 1, true), - _ => new SyncProgressInfo( - $"AI enrichment — {p.Phase}{msg}", - 0, - p.Enriched, - false) + AiEnrichmentPhase.Querying => new SyncProgressInfo($"AI enrichment — querying{msg}", totalCandidates, 0, false), + AiEnrichmentPhase.Enriching => + new SyncProgressInfo($"AI enrichment — enriching{msg}", Math.Max(1, totalCandidates), done, false), + AiEnrichmentPhase.Complete => + totalCandidates > 0 + ? new SyncProgressInfo($"AI enrichment — complete{msg}", totalCandidates, Math.Min(done, totalCandidates), true) + : new SyncProgressInfo($"AI enrichment — complete{msg}", 1, 1, true), + _ => new SyncProgressInfo($"AI enrichment — {p.Phase}{msg}", 0, p.Enriched, false) }; } } diff --git a/src/tooling/essc/LabsCrawl/AdaptiveCrawler.cs b/src/tooling/essc/LabsCrawl/AdaptiveCrawler.cs index 4f12294f2b..4ca05539d4 100644 --- a/src/tooling/essc/LabsCrawl/AdaptiveCrawler.cs +++ b/src/tooling/essc/LabsCrawl/AdaptiveCrawler.cs @@ -27,14 +27,13 @@ public void Dispose() GC.SuppressFinalize(this); } - public IAsyncEnumerable CrawlAsync( - IEnumerable urls, - CancellationToken ctx = default) => + public IAsyncEnumerable CrawlAsync(IEnumerable urls, CancellationToken ctx = default) => CrawlAsync(urls.Select(u => new CrawlDecision(u, CrawlReason.New)), ctx); public async IAsyncEnumerable CrawlAsync( IEnumerable decisions, - [EnumeratorCancellation] CancellationToken ctx = default) + [EnumeratorCancellation] CancellationToken ctx = default + ) { var decisionList = decisions.ToList(); if (decisionList.Count == 0) diff --git a/src/tooling/essc/LabsCrawl/CachedDocInfo.cs b/src/tooling/essc/LabsCrawl/CachedDocInfo.cs index aa4b7648c7..196e90fd62 100644 --- a/src/tooling/essc/LabsCrawl/CachedDocInfo.cs +++ b/src/tooling/essc/LabsCrawl/CachedDocInfo.cs @@ -4,9 +4,4 @@ namespace Elastic.SiteSearch.Cli.LabsCrawl; -public record CachedDocInfo( - string Url, - string Hash, - DateTimeOffset LastUpdated, - string? HttpEtag, - DateTimeOffset? HttpLastModified); +public record CachedDocInfo(string Url, string Hash, DateTimeOffset LastUpdated, string? HttpEtag, DateTimeOffset? HttpLastModified); diff --git a/src/tooling/essc/LabsCrawl/CachingJsonContext.cs b/src/tooling/essc/LabsCrawl/CachingJsonContext.cs index 52bfbe21b6..10f23bc2c0 100644 --- a/src/tooling/essc/LabsCrawl/CachingJsonContext.cs +++ b/src/tooling/essc/LabsCrawl/CachingJsonContext.cs @@ -6,11 +6,7 @@ namespace Elastic.SiteSearch.Cli.LabsCrawl; -[JsonSourceGenerationOptions( - WriteIndented = false, - PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull -)] +[JsonSourceGenerationOptions(WriteIndented = false, PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(SourceDocument))] internal sealed partial class CachingJsonContext : JsonSerializerContext; diff --git a/src/tooling/essc/LabsCrawl/CrawlDecision.cs b/src/tooling/essc/LabsCrawl/CrawlDecision.cs index 99cc9451f9..2e776706cb 100644 --- a/src/tooling/essc/LabsCrawl/CrawlDecision.cs +++ b/src/tooling/essc/LabsCrawl/CrawlDecision.cs @@ -11,7 +11,4 @@ public enum CrawlReason PossiblyChanged } -public record CrawlDecision( - SitemapEntry Entry, - CrawlReason Reason, - CachedDocInfo? Cached = null); +public record CrawlDecision(SitemapEntry Entry, CrawlReason Reason, CachedDocInfo? Cached = null); diff --git a/src/tooling/essc/LabsCrawl/CrawlDecisionMaker.cs b/src/tooling/essc/LabsCrawl/CrawlDecisionMaker.cs index d9f682c4bc..d53217e158 100644 --- a/src/tooling/essc/LabsCrawl/CrawlDecisionMaker.cs +++ b/src/tooling/essc/LabsCrawl/CrawlDecisionMaker.cs @@ -8,9 +8,7 @@ namespace Elastic.SiteSearch.Cli.LabsCrawl; public class CrawlDecisionMaker(ILogger logger) { - public IEnumerable MakeDecisions( - IEnumerable sitemapUrls, - IReadOnlyDictionary cache) + public IEnumerable MakeDecisions(IEnumerable sitemapUrls, IReadOnlyDictionary cache) { foreach (var entry in sitemapUrls) { @@ -32,9 +30,7 @@ public IEnumerable MakeDecisions( } } - public IEnumerable FindStaleUrls( - IReadOnlyDictionary cache, - IReadOnlySet sitemapUrls) + public IEnumerable FindStaleUrls(IReadOnlyDictionary cache, IReadOnlySet sitemapUrls) { foreach (var url in cache.Keys.Where(url => !sitemapUrls.Contains(url))) { diff --git a/src/tooling/essc/LabsCrawl/CrawlResult.cs b/src/tooling/essc/LabsCrawl/CrawlResult.cs index 57f425d0c2..bf89164ced 100644 --- a/src/tooling/essc/LabsCrawl/CrawlResult.cs +++ b/src/tooling/essc/LabsCrawl/CrawlResult.cs @@ -23,7 +23,8 @@ public static CrawlResult Succeeded( string content, DateTimeOffset? lastModified, string? etag = null, - DateTimeOffset? httpLastModified = null) => + DateTimeOffset? httpLastModified = null + ) => new() { Url = url, @@ -35,14 +36,7 @@ public static CrawlResult Succeeded( }; public static CrawlResult NotModifiedResult(string url, string cachedHash) => - new() - { - Url = url, - Success = true, - NotModified = true, - CachedHash = cachedHash, - StatusCode = 304 - }; + new() { Url = url, Success = true, NotModified = true, CachedHash = cachedHash, StatusCode = 304 }; public static CrawlResult Failed(string url, string error, int? statusCode = null) => new() { Url = url, Success = false, Error = error, StatusCode = statusCode }; diff --git a/src/tooling/essc/LabsCrawl/CrawlerRateLimiter.cs b/src/tooling/essc/LabsCrawl/CrawlerRateLimiter.cs index 5c849ac553..ab50c25819 100644 --- a/src/tooling/essc/LabsCrawl/CrawlerRateLimiter.cs +++ b/src/tooling/essc/LabsCrawl/CrawlerRateLimiter.cs @@ -15,14 +15,15 @@ private void EnsureInitialized() if (_limiter is not null || !settings.RateLimitingEnabled) return; - _limiter = new TokenBucketRateLimiter(new TokenBucketRateLimiterOptions - { - TokenLimit = settings.Rps, - ReplenishmentPeriod = TimeSpan.FromSeconds(1), - TokensPerPeriod = settings.Rps, - QueueProcessingOrder = QueueProcessingOrder.OldestFirst, - QueueLimit = 10000 - }); + _limiter = + new TokenBucketRateLimiter(new TokenBucketRateLimiterOptions + { + TokenLimit = settings.Rps, + ReplenishmentPeriod = TimeSpan.FromSeconds(1), + TokensPerPeriod = settings.Rps, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 10000 + }); } public async ValueTask AcquireAsync(CancellationToken ct = default) diff --git a/src/tooling/essc/LabsCrawl/ElasticsearchCrawlCache.cs b/src/tooling/essc/LabsCrawl/ElasticsearchCrawlCache.cs index 968350dceb..0ed05d8c90 100644 --- a/src/tooling/essc/LabsCrawl/ElasticsearchCrawlCache.cs +++ b/src/tooling/essc/LabsCrawl/ElasticsearchCrawlCache.cs @@ -8,18 +8,12 @@ namespace Elastic.SiteSearch.Cli.LabsCrawl; -public class ElasticsearchCrawlCache( - ILogger logger, - DistributedTransport transport -) +public class ElasticsearchCrawlCache(ILogger logger, DistributedTransport transport) { private const int BatchSize = 10000; private const string PitKeepAlive = "5m"; - private static readonly string[] CacheSourceIncludes = - [ - "path", "hash", "last_updated", "http.etag", "http.last_modified" - ]; + private static readonly string[] CacheSourceIncludes = ["path", "hash", "last_updated", "http.etag", "http.last_modified"]; public async Task IndexExistsAsync(string indexAlias, CancellationToken ct = default) { @@ -38,7 +32,8 @@ public async Task IndexExistsAsync(string indexAlias, CancellationToken ct public async Task> LoadCacheAsync( string indexAlias, IProgress<(int loaded, string? currentUrl)>? progress = null, - CancellationToken ct = default) + CancellationToken ct = default + ) { var cache = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -51,11 +46,12 @@ public async Task> LoadCacheAsync( Index = indexAlias, Size = BatchSize, KeepAlive = PitKeepAlive, - Sort = /*lang=json,strict*/ """{"path":"asc"}""", + Sort = /*lang=json,strict*/ """{"path":"asc"}""", SourceIncludes = CacheSourceIncludes, Slices = 1 }, - CachingJsonContext.Default.Options); + CachingJsonContext.Default.Options + ); var loaded = 0; await foreach (var page in search.SearchPagesAsync(ct)) @@ -65,12 +61,14 @@ public async Task> LoadCacheAsync( if (string.IsNullOrEmpty(src.Path)) continue; - cache[src.Path] = new CachedDocInfo( - src.Path, - src.Hash ?? string.Empty, - src.LastUpdated ?? DateTimeOffset.MinValue, - src.Http?.Etag, - src.Http?.LastModified); + cache[src.Path] = + new CachedDocInfo( + src.Path, + src.Hash ?? string.Empty, + src.LastUpdated ?? DateTimeOffset.MinValue, + src.Http?.Etag, + src.Http?.LastModified + ); loaded++; progress?.Report((loaded, src.Path)); diff --git a/src/tooling/essc/LabsCrawl/HtmlMetaExtractor.cs b/src/tooling/essc/LabsCrawl/HtmlMetaExtractor.cs index 49fc6f4b46..2bacfd6f43 100644 --- a/src/tooling/essc/LabsCrawl/HtmlMetaExtractor.cs +++ b/src/tooling/essc/LabsCrawl/HtmlMetaExtractor.cs @@ -15,7 +15,7 @@ internal static class HtmlMetaExtractor { private static readonly HashSet BlockElements = [ -with(StringComparer.OrdinalIgnoreCase), + with(StringComparer.OrdinalIgnoreCase), "p", "div", "h1", @@ -137,23 +137,16 @@ internal static class HtmlMetaExtractor public static string? GetAuthor(IHtmlDocument document) => GetMetaContent(document, "article:author") ?? GetMetaContent(document, "author"); - public static string? GetOgImage(IHtmlDocument document) => - GetMetaContent(document, "og:image"); + public static string? GetOgImage(IHtmlDocument document) => GetMetaContent(document, "og:image"); - public static string? GetTwitterImage(IHtmlDocument document) => - GetMetaContent(document, "twitter:image"); + public static string? GetTwitterImage(IHtmlDocument document) => GetMetaContent(document, "twitter:image"); - public static string? GetTwitterCard(IHtmlDocument document) => - GetMetaContent(document, "twitter:card"); + public static string? GetTwitterCard(IHtmlDocument document) => GetMetaContent(document, "twitter:card"); public static string[] ExtractHeadings(IElement container) { var headings = container.QuerySelectorAll("h1, h2, h3, h4, h5, h6"); - return headings - .Select(h => h.TextContent.Trim()) - .Where(text => !string.IsNullOrWhiteSpace(text)) - .Distinct() - .ToArray(); + return headings.Select(h => h.TextContent.Trim()).Where(text => !string.IsNullOrWhiteSpace(text)).Distinct().ToArray(); } public static string ExtractTextContent(IElement container) @@ -241,18 +234,14 @@ private static string NormalizeBlockText(string text) /// public static string CreateAbstract(string textContent, string? description, int maxLength = 400) { - var lead = textContent.Length > maxLength - ? textContent[..maxLength] + "..." - : textContent; + var lead = textContent.Length > maxLength ? textContent[..maxLength] + "..." : textContent; return string.IsNullOrWhiteSpace(description) ? lead : $"{description} {lead}"; } /// Title-cases a URL slug, e.g. "google-cloud" -> "Google Cloud". public static string TitleCaseSlug(string slug) => - string.Join(' ', slug - .Split('-', StringSplitOptions.RemoveEmptyEntries) - .Select(w => char.ToUpperInvariant(w[0]) + w[1..])); + string.Join(' ', slug.Split('-', StringSplitOptions.RemoveEmptyEntries).Select(w => char.ToUpperInvariant(w[0]) + w[1..])); /// /// Scans a listing page's article cards for <time datetime> elements and returns diff --git a/src/tooling/essc/LabsCrawl/ISitemapParser.cs b/src/tooling/essc/LabsCrawl/ISitemapParser.cs index 949642baed..02d9fc0b4d 100644 --- a/src/tooling/essc/LabsCrawl/ISitemapParser.cs +++ b/src/tooling/essc/LabsCrawl/ISitemapParser.cs @@ -9,5 +9,6 @@ public interface ISitemapParser Task> ParseAsync( Uri sitemapUrl, Action? onProgress = null, - CancellationToken ctx = default); + CancellationToken ctx = default + ); } diff --git a/src/tooling/essc/LabsCrawl/LabsHtmlExtractor.cs b/src/tooling/essc/LabsCrawl/LabsHtmlExtractor.cs index 3e82295195..098bc09bab 100644 --- a/src/tooling/essc/LabsCrawl/LabsHtmlExtractor.cs +++ b/src/tooling/essc/LabsCrawl/LabsHtmlExtractor.cs @@ -63,12 +63,12 @@ public class LabsHtmlExtractor(ILogger logger) : IDocumentExt } // Get main content - try various selectors for site pages - var contentDiv = document.QuerySelector("main") ?? - document.QuerySelector("article") ?? - document.QuerySelector(".content") ?? - document.QuerySelector(".main-content") ?? - document.QuerySelector("#content") ?? - document.Body; + var contentDiv = document.QuerySelector("main") + ?? document.QuerySelector("article") + ?? document.QuerySelector(".content") + ?? document.QuerySelector(".main-content") + ?? document.QuerySelector("#content") + ?? document.Body; if (contentDiv is null) { @@ -117,10 +117,8 @@ public class LabsHtmlExtractor(ILogger logger) : IDocumentExt var slugLabel = HtmlMetaExtractor.TitleCaseSlug(slug); (title, description) = listingKind == ListingKind.Tag - ? ($"Articles tagged with '{slugLabel}'", - $"Recent {sectionLabel} articles tagged {slug}. A curated listing of {sectionLabel} blog posts, tutorials, and articles about {slug}.") - : ($"Articles written by {slugLabel}", - $"Articles written by {slugLabel} for {sectionLabel}. A listing of {sectionLabel} blog posts, tutorials, and articles authored by {slugLabel}."); + ? ($"Articles tagged with '{slugLabel}'", $"Recent {sectionLabel} articles tagged {slug}. A curated listing of {sectionLabel} blog posts, tutorials, and articles about {slug}.") + : ($"Articles written by {slugLabel}", $"Articles written by {slugLabel} for {sectionLabel}. A listing of {sectionLabel} blog posts, tutorials, and articles authored by {slugLabel}."); textContent = string.Empty; headings = []; @@ -137,10 +135,7 @@ public class LabsHtmlExtractor(ILogger logger) : IDocumentExt // Determine last updated date // Priority: article:modified_time > sitemap lastmod > article:published_time > current time - var lastUpdated = modifiedDate - ?? sitemapLastModified - ?? publishedDate - ?? DateTimeOffset.UtcNow; + var lastUpdated = modifiedDate ?? sitemapLastModified ?? publishedDate ?? DateTimeOffset.UtcNow; // Calculate content hash var hash = ComputeHash(title + textContent); @@ -175,22 +170,9 @@ public class LabsHtmlExtractor(ILogger logger) : IDocumentExt Author = author, PublishedDate = publishedDate, ModifiedDate = modifiedDate, - Og = new OpenGraphData - { - Title = ogTitle, - Description = ogDescription, - Image = ogImage - }, - Twitter = new TwitterCardData - { - Image = twitterImage, - Card = twitterCard - }, - Http = new HttpMetadata - { - Etag = httpEtag, - LastModified = httpLastModified - } + Og = new OpenGraphData { Title = ogTitle, Description = ogDescription, Image = ogImage }, + Twitter = new TwitterCardData { Image = twitterImage, Card = twitterCard }, + Http = new HttpMetadata { Etag = httpEtag, LastModified = httpLastModified } }; } @@ -202,12 +184,18 @@ private static string ComputeHash(string content) private static readonly string[] BoilerplateSelectors = [ - "[class*='blog_ctaDivider']", // "New to Elasticsearch? Join our ..." CTA banner - "[class*='PageActions']", // Copy / Share buttons - "[class*='Rating_']", // "How helpful was this content?" feedback widget - "[class*='containerDivider']", // "Related Content" cards - "[class*='PostPreview_']", // individual related-post previews - "[class*='ready-to-build']", // bottom marketing CTA + "[class*='blog_ctaDivider']", // "New to Elasticsearch? Join our ..." CTA banner + + "[class*='PageActions']", // Copy / Share buttons + + "[class*='Rating_']", // "How helpful was this content?" feedback widget + + "[class*='containerDivider']", // "Related Content" cards + + "[class*='PostPreview_']", // individual related-post previews + + "[class*='ready-to-build']", // bottom marketing CTA + ]; /// @@ -237,9 +225,7 @@ private static void StripBoilerplate(AngleSharp.Dom.IElement root) private static int ComputeNavigationDepth(string url) { - var path = url.StartsWith("http", StringComparison.OrdinalIgnoreCase) - ? new Uri(url).AbsolutePath - : url; + var path = url.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? new Uri(url).AbsolutePath : url; return path.Split('/', StringSplitOptions.RemoveEmptyEntries).Length; } @@ -397,11 +383,13 @@ internal static string GetNavigationSection(string url) return "about"; // Product pages - if (path.Contains("/elasticsearch", StringComparison.OrdinalIgnoreCase) || - path.Contains("/kibana", StringComparison.OrdinalIgnoreCase) || - path.Contains("/observability", StringComparison.OrdinalIgnoreCase) || - path.Contains("/security", StringComparison.OrdinalIgnoreCase) || - path.Contains("/enterprise-search", StringComparison.OrdinalIgnoreCase)) + if ( + path.Contains("/elasticsearch", StringComparison.OrdinalIgnoreCase) + || path.Contains("/kibana", StringComparison.OrdinalIgnoreCase) + || path.Contains("/observability", StringComparison.OrdinalIgnoreCase) + || path.Contains("/security", StringComparison.OrdinalIgnoreCase) + || path.Contains("/enterprise-search", StringComparison.OrdinalIgnoreCase) + ) return "product"; return "marketing"; diff --git a/src/tooling/essc/LabsCrawl/LabsSiteCrawlPlanner.cs b/src/tooling/essc/LabsCrawl/LabsSiteCrawlPlanner.cs index f74d8ea487..606eb4b92f 100644 --- a/src/tooling/essc/LabsCrawl/LabsSiteCrawlPlanner.cs +++ b/src/tooling/essc/LabsCrawl/LabsSiteCrawlPlanner.cs @@ -20,13 +20,10 @@ public static class LabsSiteCrawlPlanner /// All unique URLs plus per-sitemap fetch counts (before cross-sitemap deduplication). public sealed record LabsSitemapDiscoveryResult( IReadOnlyList Urls, - IReadOnlyList<(string SitemapUrl, int RawUrlCount)> PerSitemap); + IReadOnlyList<(string SitemapUrl, int RawUrlCount)> PerSitemap + ); - public static readonly string[] DefaultExcludePaths = - [ - "/guide/", - "/downloads/past-releases/" - ]; + public static readonly string[] DefaultExcludePaths = ["/guide/", "/downloads/past-releases/"]; private static readonly string[] LangPrefixes = ["/de/", "/fr/", "/jp/", "/kr/", "/cn/", "/es/", "/pt/"]; @@ -41,7 +38,8 @@ public static async Task DiscoverUrlsAsync( ISitemapParser sitemapParser, IReadOnlyList sitemaps, IProgress<(int completed, string currentSitemap)> progress, - CancellationToken ct) + CancellationToken ct + ) { var allUrlsList = new List(); var perSitemap = new List<(string SitemapUrl, int RawUrlCount)>(); @@ -55,10 +53,7 @@ public static async Task DiscoverUrlsAsync( progress.Report((completed, currentSitemap)); } - var deduped = allUrlsList - .GroupBy(u => u.Location) - .Select(g => g.First()) - .ToList(); + var deduped = allUrlsList.GroupBy(u => u.Location).Select(g => g.First()).ToList(); return new LabsSitemapDiscoveryResult(deduped, perSitemap); } @@ -81,31 +76,33 @@ public static string SitemapDisplayLabel(string sitemapUrl) public static List FilterUrls( IReadOnlyList urls, IReadOnlyList exclusions, - HashSet? languageFilter) => urls - .Where(u => - { - var uri = new Uri(u.Location); + HashSet? languageFilter + ) => + urls.Where(u => + { + var uri = new Uri(u.Location); - if (!uri.Host.Equals("www.elastic.co", StringComparison.OrdinalIgnoreCase) && - !uri.Host.Equals("elastic.co", StringComparison.OrdinalIgnoreCase)) - return false; + if ( + !uri.Host.Equals("www.elastic.co", StringComparison.OrdinalIgnoreCase) && + !uri.Host.Equals("elastic.co", StringComparison.OrdinalIgnoreCase) + ) + return false; - foreach (var exclusion in exclusions) - { - if (uri.AbsolutePath.StartsWith(exclusion, StringComparison.OrdinalIgnoreCase)) - return false; - } + foreach (var exclusion in exclusions) + { + if (uri.AbsolutePath.StartsWith(exclusion, StringComparison.OrdinalIgnoreCase)) + return false; + } - if (languageFilter is { Count: > 0 }) - { - var lang = GetLanguageFromUrl(u.Location); - if (!languageFilter.Contains(lang)) - return false; - } + if (languageFilter is { Count: > 0 }) + { + var lang = GetLanguageFromUrl(u.Location); + if (!languageFilter.Contains(lang)) + return false; + } - return true; - }) - .ToList(); + return true; + }).ToList(); public static string GetCategory(string url) { @@ -137,7 +134,8 @@ public static CrawlPlan BuildCrawlPlan( bool unchanged, bool fair, int maxPages, - ILoggerFactory loggerFactory) + ILoggerFactory loggerFactory + ) { var decisionMaker = new CrawlDecisionMaker(loggerFactory.CreateLogger()); var allDecisions = decisionMaker.MakeDecisions(filteredUrls, cache).ToList(); @@ -155,14 +153,10 @@ public static CrawlPlan BuildCrawlPlan( var stats = CrawlDecisionMaker.GetStats(allDecisions); - var allKnownUrls = filteredUrls - .Select(u => u.Location) - .ToHashSet(StringComparer.OrdinalIgnoreCase); + var allKnownUrls = filteredUrls.Select(u => u.Location).ToHashSet(StringComparer.OrdinalIgnoreCase); var staleUrls = decisionMaker.FindStaleUrls(cache, allKnownUrls).ToList(); - var urlsToCrawl = allDecisions - .Where(d => unchanged || d.Reason != CrawlReason.Unchanged) - .ToList(); + var urlsToCrawl = allDecisions.Where(d => unchanged || d.Reason != CrawlReason.Unchanged).ToList(); if (!fair && maxPages > 0 && urlsToCrawl.Count > maxPages) urlsToCrawl = urlsToCrawl.Take(maxPages).ToList(); @@ -172,9 +166,7 @@ public static CrawlPlan BuildCrawlPlan( private static List ApplyCategoryFairness(List decisions, int maxPages) { - var byCategory = decisions - .GroupBy(d => GetCategory(d.Entry.Location)) - .ToDictionary(g => g.Key, g => g.ToList()); + var byCategory = decisions.GroupBy(d => GetCategory(d.Entry.Location)).ToDictionary(g => g.Key, g => g.ToList()); var categoryCount = byCategory.Count; if (categoryCount == 0) @@ -183,9 +175,7 @@ private static List ApplyCategoryFairness(List dec var result = new List(); var remaining = maxPages; - var sortedCategories = byCategory - .OrderByDescending(kvp => kvp.Value.Count) - .ToList(); + var sortedCategories = byCategory.OrderByDescending(kvp => kvp.Value.Count).ToList(); var taken = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -250,12 +240,7 @@ private static string GetLanguageFromUrl(string url) /// Lexical read alias for incremental cache (same index as contentstack sync). public static string ResolveLexicalReadAlias(string buildType, string environment) => - LabsMappingContext.LabsDocument - .CreateContext(type: buildType, env: environment) - .ResolveReadTarget(); + LabsMappingContext.LabsDocument.CreateContext(type: buildType, env: environment).ResolveReadTarget(); } -public sealed record CrawlPlan( - IReadOnlyList UrlsToCrawl, - IReadOnlyList StaleUrls, - CrawlDecisionStats Stats); +public sealed record CrawlPlan(IReadOnlyList UrlsToCrawl, IReadOnlyList StaleUrls, CrawlDecisionStats Stats); diff --git a/src/tooling/essc/LabsCrawl/SitemapParser.cs b/src/tooling/essc/LabsCrawl/SitemapParser.cs index 55a8180cb7..48b4fd3912 100644 --- a/src/tooling/essc/LabsCrawl/SitemapParser.cs +++ b/src/tooling/essc/LabsCrawl/SitemapParser.cs @@ -15,7 +15,8 @@ public class SitemapParser(ILogger logger, HttpClient httpClient) public async Task> ParseAsync( Uri sitemapUrl, Action? onProgress = null, - CancellationToken ctx = default) + CancellationToken ctx = default + ) { logger.LogDebug("Fetching sitemap: {Url}", sitemapUrl); onProgress?.Invoke(0, 1, sitemapUrl.ToString()); @@ -57,10 +58,10 @@ private async Task FetchContentAsync(Uri url, CancellationToken ctx) private async Task> ParseSitemapIndexAsync( XElement root, Action? onProgress, - CancellationToken ctx) + CancellationToken ctx + ) { - var sitemapUrls = root - .Elements(SitemapNs + "sitemap") + var sitemapUrls = root.Elements(SitemapNs + "sitemap") .Select(s => s.Element(SitemapNs + "loc")?.Value) .Where(loc => !string.IsNullOrWhiteSpace(loc)) .Select(loc => new Uri(loc!)) @@ -103,12 +104,7 @@ private async Task> ParseSitemapIndexAsync( } private List ParseUrlSet(XElement root) => - root - .Elements(SitemapNs + "url") - .Select(ParseUrlElement) - .Where(e => e is not null) - .Select(e => e!) - .ToList(); + root.Elements(SitemapNs + "url").Select(ParseUrlElement).Where(e => e is not null).Select(e => e!).ToList(); private static SitemapEntry? ParseUrlElement(XElement urlElement) { diff --git a/src/tooling/essc/Logging/CondensedConsoleFormatter.cs b/src/tooling/essc/Logging/CondensedConsoleFormatter.cs index 4857db5faf..28195f88a7 100644 --- a/src/tooling/essc/Logging/CondensedConsoleFormatter.cs +++ b/src/tooling/essc/Logging/CondensedConsoleFormatter.cs @@ -17,9 +17,7 @@ public class CondensedConsoleFormatter() : ConsoleFormatter("condensed") private const string Yellow = "\x1b[33m"; private const string Blue = "\x1b[34m"; - public override void Write( - in LogEntry logEntry, IExternalScopeProvider? scopeProvider, TextWriter textWriter - ) + public override void Write(in LogEntry logEntry, IExternalScopeProvider? scopeProvider, TextWriter textWriter) { var message = logEntry.Formatter.Invoke(logEntry.State, logEntry.Exception); var logLevel = GetLogLevel(logEntry.LogLevel); diff --git a/src/tooling/essc/Program.cs b/src/tooling/essc/Program.cs index 47cd5b072a..7633e15fd4 100644 --- a/src/tooling/essc/Program.cs +++ b/src/tooling/essc/Program.cs @@ -17,9 +17,7 @@ var builder = Host.CreateApplicationBuilder(args); -var isInteractive = - string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) && - !Console.IsOutputRedirected; +var isInteractive = string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) && !Console.IsOutputRedirected; builder.Logging.SetMinimumLevel(LogLevel.Warning); builder.Logging.AddFilter("System.Net.Http.HttpClient", LogLevel.Warning); @@ -40,10 +38,9 @@ var csHttpClient = builder.Services .AddHttpClient() - .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler - { - AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate - }); + .ConfigurePrimaryHttpMessageHandler( + () => new HttpClientHandler { AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate } + ); // Rate limiter is added after the resilience pipeline so it applies on each retry attempt. csHttpClient.AddStandardResilienceHandler(o => @@ -64,21 +61,22 @@ static void ConfigureLabsCrawlHttp(IHttpClientBuilder b) { - _ = b.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler - { - AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate - }); - _ = b.AddStandardResilienceHandler(o => - { - o.AttemptTimeout.Timeout = TimeSpan.FromSeconds(30); - o.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(30); - o.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(120); - o.CircuitBreaker.MinimumThroughput = 20; - o.Retry.MaxRetryAttempts = 8; - o.Retry.UseJitter = true; - o.Retry.BackoffType = Polly.DelayBackoffType.Exponential; - o.Retry.Delay = TimeSpan.FromSeconds(2); - }); + _ = + b.ConfigurePrimaryHttpMessageHandler( + () => new HttpClientHandler { AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate } + ); + _ = + b.AddStandardResilienceHandler(o => + { + o.AttemptTimeout.Timeout = TimeSpan.FromSeconds(30); + o.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(30); + o.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(120); + o.CircuitBreaker.MinimumThroughput = 20; + o.Retry.MaxRetryAttempts = 8; + o.Retry.UseJitter = true; + o.Retry.BackoffType = Polly.DelayBackoffType.Exponential; + o.Retry.Delay = TimeSpan.FromSeconds(2); + }); } ConfigureLabsCrawlHttp(builder.Services.AddHttpClient()); @@ -95,12 +93,15 @@ static void ConfigureLabsCrawlHttp(IHttpClientBuilder b) args, argh => { - _ = argh.UseCliDescription( - "Elastic Site Search CLI — tooling to ingest and enrich data published on elastic.co (not the Elastic documentation site)."); + _ = + argh.UseCliDescription( + "Elastic Site Search CLI — tooling to ingest and enrich data published on elastic.co (not the Elastic documentation site)." + ); _ = argh.MapNamespace("contentstack"); _ = argh.MapNamespace("labs"); _ = argh.MapNamespace("indices"); - }); + } +); using var host = builder.Build(); await host.RunAsync().ConfigureAwait(false); diff --git a/tests-integration/Elastic.ContentDateEnrichment.IntegrationTests/ContentDateEnrichmentTests.cs b/tests-integration/Elastic.ContentDateEnrichment.IntegrationTests/ContentDateEnrichmentTests.cs index 08f185cb7b..23610883f6 100644 --- a/tests-integration/Elastic.ContentDateEnrichment.IntegrationTests/ContentDateEnrichmentTests.cs +++ b/tests-integration/Elastic.ContentDateEnrichment.IntegrationTests/ContentDateEnrichmentTests.cs @@ -28,15 +28,15 @@ public class ElasticsearchFixture : IAsyncLifetime public async ValueTask InitializeAsync() { - _container = new ContainerBuilder() - .WithImage("docker.elastic.co/elasticsearch/elasticsearch:8.18.0") - .WithEnvironment("discovery.type", "single-node") - .WithEnvironment("xpack.security.enabled", "false") - .WithEnvironment("xpack.security.http.ssl.enabled", "false") - .WithPortBinding(9200, true) - .WithWaitStrategy(Wait.ForUnixContainer() - .UntilHttpRequestIsSucceeded(r => r.ForPort(9200).ForPath("/_cluster/health"))) - .Build(); + _container = + new ContainerBuilder() + .WithImage("docker.elastic.co/elasticsearch/elasticsearch:8.18.0") + .WithEnvironment("discovery.type", "single-node") + .WithEnvironment("xpack.security.enabled", "false") + .WithEnvironment("xpack.security.http.ssl.enabled", "false") + .WithPortBinding(9200, true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPort(9200).ForPath("/_cluster/health"))) + .Build(); await _container.StartAsync(); @@ -72,9 +72,7 @@ private Elastic.Markdown.Exporters.Elasticsearch.ContentDateEnrichment CreateEnr var logger = loggerFactory.CreateLogger(); var operations = new ElasticsearchOperations(_transport, logger); // Each test uses a unique buildType to isolate its pipeline/lookup infrastructure - return new Elastic.Markdown.Exporters.Elasticsearch.ContentDateEnrichment( - _transport, operations, logger, testName, "test" - ); + return new Elastic.Markdown.Exporters.Elasticsearch.ContentDateEnrichment(_transport, operations, logger, testName, "test"); } /// @@ -87,7 +85,8 @@ private Elastic.Markdown.Exporters.Elasticsearch.ContentDateEnrichment CreateEnr private async Task> CreateChannelAsync( Elastic.Markdown.Exporters.Elasticsearch.ContentDateEnrichment enrichment, string testName, - string? indexNameOverride = null) + string? indexNameOverride = null + ) { var synonymSetName = $"docs-{testName}-test"; @@ -99,14 +98,10 @@ await _transport.PutAsync( CancellationToken.None ); - var typeContext = DocumentationMappingContext.DocumentationDocument - .CreateContext(type: testName, env: "test") with + var typeContext = DocumentationMappingContext.DocumentationDocument.CreateContext(type: testName, env: "test") with { ConfigureAnalysis = a => SharedAnalysisFactory.BuildAnalysis(a, synonymSetName, ["test, testing"]), - IndexSettings = new Dictionary - { - ["index.default_pipeline"] = enrichment.PipelineName - } + IndexSettings = new Dictionary { ["index.default_pipeline"] = enrichment.PipelineName } }; var options = new IngestChannelOptions(_transport, typeContext, indexNameOverride: indexNameOverride) @@ -119,9 +114,7 @@ await _transport.PutAsync( /// /// Writes documents through the real IngestChannel (HashedBulkUpdate) and waits for drain. /// - private static async Task WriteDocuments( - IngestChannel channel, - params DocumentationDocument[] docs) + private static async Task WriteDocuments(IngestChannel channel, params DocumentationDocument[] docs) { foreach (var doc in docs) await channel.WaitToWriteAsync(doc, CancellationToken.None); @@ -129,14 +122,8 @@ private static async Task WriteDocuments( await channel.WaitForDrainAsync(TimeSpan.FromSeconds(10), CancellationToken.None); } - private static DocumentationDocument CreateDoc(string url, string contentHash, string title) => new() - { - Path = url, - Title = title, - SearchTitle = title, - Hash = contentHash, - ContentBodyHash = contentHash - }; + private static DocumentationDocument CreateDoc(string url, string contentHash, string title) => + new() { Path = url, Title = title, SearchTitle = title, Hash = contentHash, ContentBodyHash = contentHash }; [Fact] public async Task FirstRun_AllDocumentsGetCurrentTimestamp() @@ -149,10 +136,7 @@ public async Task FirstRun_AllDocumentsGetCurrentTimestamp() await channel.BootstrapElasticsearchAsync(BootstrapMethod.Failure, CancellationToken.None); var index = channel.IndexName; - await WriteDocuments(channel, - CreateDoc("url1", "hash_a", "Doc 1"), - CreateDoc("url2", "hash_b", "Doc 2") - ); + await WriteDocuments(channel, CreateDoc("url1", "hash_a", "Doc 1"), CreateDoc("url2", "hash_b", "Doc 2")); await channel.RefreshAsync(CancellationToken.None); index = await ResolveIndexName(index); @@ -170,8 +154,11 @@ await WriteDocuments(channel, foreach (var doc in docs) { doc.ContentLastUpdated.Should().NotBeNull($"document {doc.Url} should have content_last_updated"); - doc.ContentLastUpdated.Value.Year.Should().BeGreaterThanOrEqualTo(2026, - $"document {doc.Url} should have a recent content_last_updated"); + doc.ContentLastUpdated + .Value + .Year + .Should() + .BeGreaterThanOrEqualTo(2026, $"document {doc.Url} should have a recent content_last_updated"); } } @@ -188,10 +175,7 @@ public async Task SecondRun_UnchangedContentPreservesOldDate() await channel1.BootstrapElasticsearchAsync(BootstrapMethod.Failure, CancellationToken.None); index = channel1.IndexName; // wildcard — resolved to concrete name below - await WriteDocuments(channel1, - CreateDoc("url1", "hash_a", "Doc 1"), - CreateDoc("url2", "hash_b", "Doc 2") - ); + await WriteDocuments(channel1, CreateDoc("url1", "hash_a", "Doc 1"), CreateDoc("url2", "hash_b", "Doc 2")); await channel1.RefreshAsync(CancellationToken.None); } index = await ResolveIndexName(index); @@ -213,7 +197,8 @@ await WriteDocuments(channel1, { await channel2.BootstrapElasticsearchAsync(BootstrapMethod.Failure, CancellationToken.None); - await WriteDocuments(channel2, + await WriteDocuments( + channel2, CreateDoc("url1", "hash_a", "Doc 1 (re-indexed)"), CreateDoc("url2", "hash_b", "Doc 2 (re-indexed)") ); @@ -232,8 +217,9 @@ await WriteDocuments(channel2, foreach (var doc in secondRunDocs) { var originalDate = firstRunDates[doc.Url]; - doc.ContentLastUpdated.Should().Be(originalDate!.Value, - $"document {doc.Url} content didn't change, so content_last_updated should be preserved"); + doc.ContentLastUpdated + .Should() + .Be(originalDate!.Value, $"document {doc.Url} content didn't change, so content_last_updated should be preserved"); } } @@ -250,10 +236,7 @@ public async Task SecondRun_ChangedContentGetsNewDate() await channel1.BootstrapElasticsearchAsync(BootstrapMethod.Failure, CancellationToken.None); index = channel1.IndexName; // wildcard — resolved to concrete name below - await WriteDocuments(channel1, - CreateDoc("url1", "hash_a", "Doc 1"), - CreateDoc("url2", "hash_b", "Doc 2") - ); + await WriteDocuments(channel1, CreateDoc("url1", "hash_a", "Doc 1"), CreateDoc("url2", "hash_b", "Doc 2")); await channel1.RefreshAsync(CancellationToken.None); } index = await ResolveIndexName(index); @@ -274,7 +257,8 @@ await WriteDocuments(channel1, await channel2.BootstrapElasticsearchAsync(BootstrapMethod.Failure, CancellationToken.None); // url1 content changed, url2 unchanged - await WriteDocuments(channel2, + await WriteDocuments( + channel2, CreateDoc("url1", "hash_CHANGED", "Doc 1 (updated content)"), CreateDoc("url2", "hash_b", "Doc 2 (same content)") ); @@ -293,11 +277,13 @@ await WriteDocuments(channel2, var changed = secondRunDocs.Single(d => d.Url == "url1"); var unchanged = secondRunDocs.Single(d => d.Url == "url2"); - changed.ContentLastUpdated.Should().BeAfter(firstRunDates["url1"]!.Value, - "url1 content changed, so content_last_updated should advance"); + changed.ContentLastUpdated + .Should() + .BeAfter(firstRunDates["url1"]!.Value, "url1 content changed, so content_last_updated should advance"); - unchanged.ContentLastUpdated.Should().Be(firstRunDates["url2"]!.Value, - "url2 content didn't change, so content_last_updated should be preserved"); + unchanged.ContentLastUpdated + .Should() + .Be(firstRunDates["url2"]!.Value, "url2 content didn't change, so content_last_updated should be preserved"); } /// @@ -332,11 +318,11 @@ public async Task ScriptedUpsert_WithFullDocument_ContentLastUpdatedValue() await RefreshIndex(index); // Read back from ES - var response = await _transport.GetAsync( - $"{index}/_doc/{doc.Path}", CancellationToken.None - ); - response.ApiCallDetails.HasSuccessfulStatusCode.Should().BeTrue( - $"Failed to get document: {response.ApiCallDetails.DebugInformation}"); + var response = await _transport.GetAsync($"{index}/_doc/{doc.Path}", CancellationToken.None); + response.ApiCallDetails + .HasSuccessfulStatusCode + .Should() + .BeTrue($"Failed to get document: {response.ApiCallDetails.DebugInformation}"); var source = JsonNode.Parse(response.Body)?["_source"]; source.Should().NotBeNull(); @@ -347,8 +333,10 @@ public async Task ScriptedUpsert_WithFullDocument_ContentLastUpdatedValue() esDateValue.Should().NotBeNull("content_last_updated should be present in ES document"); var esDate = DateTimeOffset.Parse(esDateValue, CultureInfo.InvariantCulture); esDate.Year.Should().Be(1, "default DateTimeOffset.MinValue should serialize as year 0001"); - esDate.Should().BeBefore(DateTimeOffset.UnixEpoch, - "the default value should be well before 1970, making it safe to filter with 'must_not range gt 1970'"); + esDate.Should().BeBefore( + DateTimeOffset.UnixEpoch, + "the default value should be well before 1970, making it safe to filter with 'must_not range gt 1970'" + ); } /// @@ -369,7 +357,8 @@ public async Task FilteredResolve_SkipsDocumentsWithExistingDates() await channel1.BootstrapElasticsearchAsync(BootstrapMethod.Failure, CancellationToken.None); index = channel1.IndexName; // wildcard -- resolved to concrete name below - await WriteDocuments(channel1, + await WriteDocuments( + channel1, CreateDoc("url1", "hash_a", "Doc 1"), CreateDoc("url2", "hash_b", "Doc 2"), CreateDoc("url3", "hash_c", "Doc 3") @@ -392,7 +381,8 @@ await WriteDocuments(channel1, { await channel2.BootstrapElasticsearchAsync(BootstrapMethod.Failure, CancellationToken.None); - await WriteDocuments(channel2, + await WriteDocuments( + channel2, CreateDoc("url1", "hash_CHANGED", "Doc 1 updated"), CreateDoc("url2", "hash_b", "Doc 2"), CreateDoc("url3", "hash_c", "Doc 3") @@ -415,16 +405,13 @@ await WriteDocuments(channel2, var result1 = docs.Single(d => d.Url == "url1"); result1.ContentLastUpdated.Should().NotBeNull("url1 changed — should have been resolved"); - result1.ContentLastUpdated!.Value.Should().BeAfter(firstRunDates["url1"]!.Value, - "url1 content changed — date should advance"); + result1.ContentLastUpdated!.Value.Should().BeAfter(firstRunDates["url1"]!.Value, "url1 content changed — date should advance"); var result2 = docs.Single(d => d.Url == "url2"); - result2.ContentLastUpdated!.Value.Should().Be(firstRunDates["url2"]!.Value, - "url2 was a noop — filter should have skipped it"); + result2.ContentLastUpdated!.Value.Should().Be(firstRunDates["url2"]!.Value, "url2 was a noop — filter should have skipped it"); var result3 = docs.Single(d => d.Url == "url3"); - result3.ContentLastUpdated!.Value.Should().Be(firstRunDates["url3"]!.Value, - "url3 was a noop — filter should have skipped it"); + result3.ContentLastUpdated!.Value.Should().Be(firstRunDates["url3"]!.Value, "url3 was a noop — filter should have skipped it"); } /// @@ -445,7 +432,8 @@ public async Task SecondRun_WithFilter_OnlyChangedDocGetsNewDate() await channel1.BootstrapElasticsearchAsync(BootstrapMethod.Failure, CancellationToken.None); index = channel1.IndexName; // wildcard — resolved to concrete name below - await WriteDocuments(channel1, + await WriteDocuments( + channel1, CreateDoc("url1", "hash_a", "Doc 1"), CreateDoc("url2", "hash_b", "Doc 2"), CreateDoc("url3", "hash_c", "Doc 3") @@ -468,7 +456,8 @@ await WriteDocuments(channel1, { await channel2.BootstrapElasticsearchAsync(BootstrapMethod.Failure, CancellationToken.None); - await WriteDocuments(channel2, + await WriteDocuments( + channel2, CreateDoc("url1", "hash_CHANGED", "Doc 1 updated"), CreateDoc("url2", "hash_b", "Doc 2"), CreateDoc("url3", "hash_c", "Doc 3") @@ -489,33 +478,28 @@ await WriteDocuments(channel2, var secondRunDocs = await GetAllDocuments(index); var changed = secondRunDocs.Single(d => d.Url == "url1"); - changed.ContentLastUpdated.Should().BeAfter(firstRunDates["url1"]!.Value, - "url1 content changed — date should advance"); + changed.ContentLastUpdated.Should().BeAfter(firstRunDates["url1"]!.Value, "url1 content changed — date should advance"); var unchanged2 = secondRunDocs.Single(d => d.Url == "url2"); - unchanged2.ContentLastUpdated.Should().Be(firstRunDates["url2"]!.Value, - "url2 was a noop — filter should have skipped it, preserving its date"); + unchanged2.ContentLastUpdated + .Should() + .Be(firstRunDates["url2"]!.Value, "url2 was a noop — filter should have skipped it, preserving its date"); var unchanged3 = secondRunDocs.Single(d => d.Url == "url3"); - unchanged3.ContentLastUpdated.Should().Be(firstRunDates["url3"]!.Value, - "url3 was a noop — filter should have skipped it, preserving its date"); + unchanged3.ContentLastUpdated + .Should() + .Be(firstRunDates["url3"]!.Value, "url3 was a noop — filter should have skipped it, preserving its date"); } // --- Helpers --- private async Task CreateTestIndex(string index, string pipelineName) { - _ = await _transport.DeleteAsync( - index, new DefaultRequestParameters(), PostData.Empty, CancellationToken.None - ); + _ = await _transport.DeleteAsync(index, new DefaultRequestParameters(), PostData.Empty, CancellationToken.None); var body = new JsonObject { - ["settings"] = new JsonObject - { - ["index.default_pipeline"] = pipelineName, - ["number_of_replicas"] = 0 - }, + ["settings"] = new JsonObject { ["index.default_pipeline"] = pipelineName, ["number_of_replicas"] = 0 }, ["mappings"] = new JsonObject { ["properties"] = new JsonObject @@ -528,11 +512,11 @@ private async Task CreateTestIndex(string index, string pipelineName) } }; - var response = await _transport.PutAsync( - index, PostData.String(body.ToJsonString()), CancellationToken.None - ); - response.ApiCallDetails.HasSuccessfulStatusCode.Should().BeTrue( - $"Failed to create test index: {response.ApiCallDetails.DebugInformation}"); + var response = await _transport.PutAsync(index, PostData.String(body.ToJsonString()), CancellationToken.None); + response.ApiCallDetails + .HasSuccessfulStatusCode + .Should() + .BeTrue($"Failed to create test index: {response.ApiCallDetails.DebugInformation}"); } /// @@ -562,28 +546,23 @@ private async Task IndexDocumentsDirectly(string index, params (string url, stri /// Uses the _index API which DOES trigger the default_pipeline. private async Task IndexViaIndexAction(string index, string url, string contentHash, string title) { - var doc = new JsonObject - { - ["url"] = url, - ["content_hash"] = contentHash, - ["title"] = title - }; - - var response = await _transport.PutAsync( - $"{index}/_doc/{url}", - PostData.String(doc.ToJsonString()), - CancellationToken.None - ); - response.ApiCallDetails.HasSuccessfulStatusCode.Should().BeTrue( - $"Failed to index document {url}: {response.ApiCallDetails.DebugInformation}"); + var doc = new JsonObject { ["url"] = url, ["content_hash"] = contentHash, ["title"] = title }; + + var response = + await _transport.PutAsync($"{index}/_doc/{url}", PostData.String(doc.ToJsonString()), CancellationToken.None); + response.ApiCallDetails + .HasSuccessfulStatusCode + .Should() + .BeTrue($"Failed to index document {url}: {response.ApiCallDetails.DebugInformation}"); } - private async Task GetDocument(string index, string id) { var response = await _transport.GetAsync($"{index}/_doc/{id}", CancellationToken.None); - response.ApiCallDetails.HasSuccessfulStatusCode.Should().BeTrue( - $"Failed to get document {id}: {response.ApiCallDetails.DebugInformation}"); + response.ApiCallDetails + .HasSuccessfulStatusCode + .Should() + .BeTrue($"Failed to get document {id}: {response.ApiCallDetails.DebugInformation}"); var source = JsonNode.Parse(response.Body)?["_source"]; source.Should().NotBeNull(); @@ -591,21 +570,17 @@ private async Task GetDocument(string index, string id) var dateStr = source["content_last_updated"]?.GetValue(); DateTimeOffset? date = dateStr is not null ? DateTimeOffset.Parse(dateStr, CultureInfo.InvariantCulture) : null; - return new TestDocument( - source["url"]?.GetValue() ?? "", - source["content_hash"]?.GetValue() ?? "", - date - ); + return new TestDocument(source["url"]?.GetValue() ?? "", source["content_hash"]?.GetValue() ?? "", date); } /// Resolves the concrete index name from a wildcard pattern or alias. private async Task ResolveIndexName(string indexPattern) { - var response = await _transport.GetAsync( - $"/_resolve/index/{indexPattern}", CancellationToken.None - ); - response.ApiCallDetails.HasSuccessfulStatusCode.Should().BeTrue( - $"Failed to resolve index pattern {indexPattern}: {response.ApiCallDetails.DebugInformation}"); + var response = await _transport.GetAsync($"/_resolve/index/{indexPattern}", CancellationToken.None); + response.ApiCallDetails + .HasSuccessfulStatusCode + .Should() + .BeTrue($"Failed to resolve index pattern {indexPattern}: {response.ApiCallDetails.DebugInformation}"); var json = JsonNode.Parse(response.Body); var indices = json?["indices"]?.AsArray(); @@ -619,25 +594,16 @@ private async Task RefreshIndex(string index) => private async Task> GetAllDocuments(string index) { - var body = new JsonObject - { - ["size"] = 100, - ["_source"] = new JsonArray("url", "content_hash", "content_last_updated", "title") - }; + var body = new JsonObject { ["size"] = 100, ["_source"] = new JsonArray("url", "content_hash", "content_last_updated", "title") }; - var response = await _transport.PostAsync( - $"{index}/_search", - PostData.String(body.ToJsonString()), - CancellationToken.None - ); - response.ApiCallDetails.HasSuccessfulStatusCode.Should().BeTrue( - $"Search failed: {response.ApiCallDetails.DebugInformation}"); + var response = + await _transport.PostAsync($"{index}/_search", PostData.String(body.ToJsonString()), CancellationToken.None); + response.ApiCallDetails.HasSuccessfulStatusCode.Should().BeTrue($"Search failed: {response.ApiCallDetails.DebugInformation}"); var json = JsonNode.Parse(response.Body); var hits = json?["hits"]?["hits"]?.AsArray() ?? []; - return hits - .Where(h => h?["_source"] is not null) + return hits.Where(h => h?["_source"] is not null) .Select(h => { var source = h!["_source"]!; @@ -661,14 +627,7 @@ private async Task IndexFullDocumentViaScriptedUpsert(string index, string id, s var docNode = JsonNode.Parse(serializedDoc)!; var hashValue = docNode[hashField]?.ToString() ?? ""; - var actionLine = new JsonObject - { - ["update"] = new JsonObject - { - ["_index"] = index, - ["_id"] = id - } - }; + var actionLine = new JsonObject { ["update"] = new JsonObject { ["_index"] = index, ["_id"] = id } }; // Matches HashedBulkUpdate's script: if hash matches -> noop, else replace source var bodyLine = new JsonObject @@ -677,29 +636,20 @@ private async Task IndexFullDocumentViaScriptedUpsert(string index, string id, s ["upsert"] = new JsonObject(), ["script"] = new JsonObject { - ["source"] = $"if (ctx._source.{hashField} == params.hash) {{ ctx.op = 'noop' }} else {{ ctx._source = params.doc; ctx._source.{hashField} = params.hash }}", - ["params"] = new JsonObject - { - ["hash"] = hashValue, - ["doc"] = docNode - } + ["source"] = + $"if (ctx._source.{hashField} == params.hash) {{ ctx.op = 'noop' }} else {{ ctx._source = params.doc; ctx._source.{hashField} = params.hash }}", + ["params"] = new JsonObject { ["hash"] = hashValue, ["doc"] = docNode } } }; var bulkBody = $"{actionLine.ToJsonString()}\n{bodyLine.ToJsonString()}\n"; - var response = await _transport.PostAsync( - "/_bulk", - PostData.String(bulkBody), - CancellationToken.None - ); - response.ApiCallDetails.HasSuccessfulStatusCode.Should().BeTrue( - $"Bulk update failed: {response.ApiCallDetails.DebugInformation}"); + var response = await _transport.PostAsync("/_bulk", PostData.String(bulkBody), CancellationToken.None); + response.ApiCallDetails.HasSuccessfulStatusCode.Should().BeTrue($"Bulk update failed: {response.ApiCallDetails.DebugInformation}"); var bulkResult = JsonNode.Parse(response.Body); bulkResult.Should().NotBeNull("bulk response body should be valid JSON"); bulkResult!["errors"].Should().NotBeNull("bulk response should contain an 'errors' field"); - bulkResult["errors"]!.GetValue().Should().BeFalse( - $"Bulk response contained item errors: {response.Body}"); + bulkResult["errors"]!.GetValue().Should().BeFalse($"Bulk response contained item errors: {response.Body}"); } private sealed record TestDocument(string Url, string ContentHash, DateTimeOffset? ContentLastUpdated); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/AssembleFixture.cs b/tests-integration/Elastic.Documentation.IntegrationTests/AssembleFixture.cs index 593714e805..f354019403 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/AssembleFixture.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/AssembleFixture.cs @@ -22,8 +22,7 @@ public static class DistributedApplicationExtensions /// /// Ensures all parameters in the application configuration have values set. /// - public static TBuilder WithEmptyParameters(this TBuilder builder) - where TBuilder : IDistributedApplicationTestingBuilder + public static TBuilder WithEmptyParameters(this TBuilder builder) where TBuilder : IDistributedApplicationTestingBuilder { var parameters = builder.Resources.OfType().Where(p => !p.IsConnectionString).ToList(); foreach (var parameter in parameters) @@ -39,7 +38,6 @@ public static TBuilder WithEmptyParameters(this TBuilder builder) } } - public class DocumentationFixture : IAsyncLifetime { public DistributedApplication DistributedApplication { get; private set; } = null!; @@ -55,56 +53,72 @@ public async ValueTask InitializeAsync() ? ["--skip-private-repositories", "--assume-cloned"] : ["--skip-private-repositories", "--assume-cloned", "--assume-build"]; - var builder = await DistributedApplicationTestingBuilder.CreateAsync( - args, - (options, _) => - { - options.DisableDashboard = true; - options.AllowUnsecuredTransport = true; - options.EnableResourceLogging = true; - } - ); + var builder = + await DistributedApplicationTestingBuilder.CreateAsync( + args, + (options, _) => + { + options.DisableDashboard = true; + options.AllowUnsecuredTransport = true; + options.EnableResourceLogging = true; + } + ); _ = builder.WithEmptyParameters(); _ = builder.Services.AddElasticDocumentationLogging(LogLevel.Information); _ = builder.Services.AddLogging(c => c.AddXUnit()); _ = builder.Services.AddLogging(c => c.AddInMemory()); - DistributedApplication = await builder.BuildAsync(); InMemoryLogger = DistributedApplication.Services.GetService()!; _ = DistributedApplication.StartAsync().WaitAsync(TimeSpan.FromMinutes(5), TestContext.Current.CancellationToken); - _ = await DistributedApplication.ResourceNotifications - .WaitForResourceAsync(AssemblerClone, KnownResourceStates.TerminalStates, cancellationToken: TestContext.Current.CancellationToken) - .WaitAsync(TimeSpan.FromMinutes(5), TestContext.Current.CancellationToken); + _ = + await DistributedApplication.ResourceNotifications + .WaitForResourceAsync( + AssemblerClone, + KnownResourceStates.TerminalStates, + cancellationToken: TestContext.Current.CancellationToken + ) + .WaitAsync(TimeSpan.FromMinutes(5), TestContext.Current.CancellationToken); await ValidateExitCode(AssemblerClone); - _ = await DistributedApplication.ResourceNotifications - .WaitForResourceAsync(AssemblerBuild, KnownResourceStates.TerminalStates, cancellationToken: TestContext.Current.CancellationToken) - .WaitAsync(TimeSpan.FromMinutes(5), TestContext.Current.CancellationToken); + _ = + await DistributedApplication.ResourceNotifications + .WaitForResourceAsync( + AssemblerBuild, + KnownResourceStates.TerminalStates, + cancellationToken: TestContext.Current.CancellationToken + ) + .WaitAsync(TimeSpan.FromMinutes(5), TestContext.Current.CancellationToken); await ValidateExitCode(AssemblerBuild); try { - _ = await DistributedApplication.ResourceNotifications - .WaitForResourceHealthyAsync(AssemblerServe, cancellationToken: TestContext.Current.CancellationToken) - .WaitAsync(TimeSpan.FromMinutes(3), TestContext.Current.CancellationToken); - - _ = await DistributedApplication.ResourceNotifications - .WaitForResourceHealthyAsync(ResourceNames.Api, cancellationToken: TestContext.Current.CancellationToken) - .WaitAsync(TimeSpan.FromMinutes(3), TestContext.Current.CancellationToken); - - _ = await DistributedApplication.ResourceNotifications - .WaitForResourceHealthyAsync(RemoteMcp, cancellationToken: TestContext.Current.CancellationToken) - .WaitAsync(TimeSpan.FromMinutes(3), TestContext.Current.CancellationToken); + _ = + await DistributedApplication.ResourceNotifications + .WaitForResourceHealthyAsync(AssemblerServe, cancellationToken: TestContext.Current.CancellationToken) + .WaitAsync(TimeSpan.FromMinutes(3), TestContext.Current.CancellationToken); + + _ = + await DistributedApplication.ResourceNotifications + .WaitForResourceHealthyAsync(ResourceNames.Api, cancellationToken: TestContext.Current.CancellationToken) + .WaitAsync(TimeSpan.FromMinutes(3), TestContext.Current.CancellationToken); + + _ = + await DistributedApplication.ResourceNotifications + .WaitForResourceHealthyAsync(RemoteMcp, cancellationToken: TestContext.Current.CancellationToken) + .WaitAsync(TimeSpan.FromMinutes(3), TestContext.Current.CancellationToken); } catch (Exception e) { await DistributedApplication.StopAsync(); await DistributedApplication.DisposeAsync(); - throw new Exception($"{e.Message}: {string.Join(Environment.NewLine, InMemoryLogger.RecordedLogs.Reverse().Take(30).Reverse())}", e); + throw new Exception( + $"{e.Message}: {string.Join(Environment.NewLine, InMemoryLogger.RecordedLogs.Reverse().Take(30).Reverse())}", + e + ); } } @@ -123,7 +137,8 @@ private async ValueTask ValidateExitCode(string resourceName) await DistributedApplication.StopAsync(); await DistributedApplication.DisposeAsync(); throw new Exception( - $"Exit code should be 0 for {resourceName}: {string.Join(Environment.NewLine, InMemoryLogger.RecordedLogs.Reverse().Take(30).Reverse())}"); + $"Exit code should be 0 for {resourceName}: {string.Join(Environment.NewLine, InMemoryLogger.RecordedLogs.Reverse().Take(30).Reverse())}" + ); } } diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs index 22c4dba606..28e2a715be 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs @@ -22,11 +22,14 @@ public class PublicOnlyAssemblerConfigurationTests public PublicOnlyAssemblerConfigurationTests() { FileSystem = new FileSystem(); - CheckoutDirectory = FileSystem.DirectoryInfo.New( - FileSystem.Path.Join(Paths.GetSolutionDirectory()!.FullName, ".artifacts", "checkouts") - ); + CheckoutDirectory = + FileSystem.DirectoryInfo.New(FileSystem.Path.Join(Paths.GetSolutionDirectory()!.FullName, ".artifacts", "checkouts")); Collector = new DiagnosticsCollector([]); - var configurationFileProvider = new ConfigurationFileProvider(NullLoggerFactory.Instance, new ConfigurationFileSystem(FileSystem), skipPrivateRepositories: true); + var configurationFileProvider = new ConfigurationFileProvider( + NullLoggerFactory.Instance, + new ConfigurationFileSystem(FileSystem), + skipPrivateRepositories: true + ); var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem, configurationFileProvider: configurationFileProvider); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var assembleFs = CheckoutsFileSystem.FromWorkingDirectory(FileSystem); @@ -41,10 +44,8 @@ public void ReadsPrivateRepositories() config.PrivateRepositories.Should().NotBeEmpty().And.ContainKey("cloud"); var cloud = config.PrivateRepositories["cloud"]; cloud.Should().NotBeNull(); - cloud.GitReferenceCurrent.Should().NotBeNullOrEmpty() - .And.Be("master"); + cloud.GitReferenceCurrent.Should().NotBeNullOrEmpty().And.Be("master"); } - } public class AssemblerConfigurationTests : IAsyncLifetime @@ -60,9 +61,8 @@ public AssemblerConfigurationTests(DocumentationFixture fixture, ITestOutputHelp _fixture = fixture; _output = output; FileSystem = new FileSystem(); - CheckoutDirectory = FileSystem.DirectoryInfo.New( - FileSystem.Path.Join(Paths.GetSolutionDirectory()!.FullName, ".artifacts", "checkouts") - ); + CheckoutDirectory = + FileSystem.DirectoryInfo.New(FileSystem.Path.Join(Paths.GetSolutionDirectory()!.FullName, ".artifacts", "checkouts")); Collector = new DiagnosticsCollector([]); var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); @@ -85,8 +85,7 @@ public void ReadsConfigurationFiles() public void ReadsContentSource() { var environments = Context.Configuration.Environments; - environments.Should().NotBeEmpty() - .And.ContainKey("prod"); + environments.Should().NotBeEmpty().And.ContainKey("prod"); var prod = environments["prod"]; prod.ContentSource.Should().Be(ContentSource.Current); @@ -100,8 +99,7 @@ public void StagingEnvironment_EnablesAssemblerApiExplorerFlag() { var staging = Context.Configuration.Environments["staging"]; - staging.FeatureFlags.Should().ContainKey("ASSEMBLER_API_EXPLORER") - .WhoseValue.Should().BeTrue(); + staging.FeatureFlags.Should().ContainKey("ASSEMBLER_API_EXPLORER").WhoseValue.Should().BeTrue(); } [Fact] @@ -116,8 +114,7 @@ public void ProdEnvironment_DoesNotEnableAssemblerApiExplorerFlag() public void ReadsVersions() { var config = Context.Configuration; - config.SharedConfigurations.Should().NotBeEmpty() - .And.ContainKey("stack"); + config.SharedConfigurations.Should().NotBeEmpty().And.ContainKey("stack"); config.SharedConfigurations["stack"].GitReferenceEdge.Should().NotBeNullOrEmpty(); @@ -127,20 +124,15 @@ public void ReadsVersions() // test defaults var apmServer = config.ReferenceRepositories["apm-server"]; - apmServer.GitReferenceNext.Should().NotBeNullOrEmpty() - .And.Be("main"); - apmServer.GitReferenceCurrent.Should().NotBeNullOrEmpty() - .And.Be("main"); - apmServer.GitReferenceEdge.Should().NotBeNullOrEmpty() - .And.Be("main"); + apmServer.GitReferenceNext.Should().NotBeNullOrEmpty().And.Be("main"); + apmServer.GitReferenceCurrent.Should().NotBeNullOrEmpty().And.Be("main"); + apmServer.GitReferenceEdge.Should().NotBeNullOrEmpty().And.Be("main"); var beats = config.ReferenceRepositories["beats"]; - beats.GitReferenceCurrent.Should().NotBeNullOrEmpty() - .And.NotBe("main"); + beats.GitReferenceCurrent.Should().NotBeNullOrEmpty().And.NotBe("main"); var curator = config.ReferenceRepositories["curator"]; - curator.GitReferenceCurrent.Should().NotBeNullOrEmpty() - .And.Be("master"); + curator.GitReferenceCurrent.Should().NotBeNullOrEmpty().And.Be("master"); } /// diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs index 096528a084..169c4ff7e5 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs @@ -32,40 +32,44 @@ public async Task TestPlan() IReadOnlyCollection diagnosticsOutputs = []; var collector = new DiagnosticsCollector(diagnosticsOutputs); var mockS3Client = A.Fake(); - var fileSystem = new MockFileSystem(new Dictionary - { - { "docs/add1.md", new MockFileData("# New Document 1") }, - { "docs/add2.md", new MockFileData("# New Document 2") }, - { "docs/add3.md", new MockFileData("# New Document 3") }, - { "docs/skip.md", new MockFileData("# Skipped Document") }, - { "docs/update.md", new MockFileData("# Existing Document") }, - }, new MockFileSystemOptions - { - CurrentDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly"), - }); + var fileSystem = new MockFileSystem( + new Dictionary + { + { "docs/add1.md", new MockFileData("# New Document 1") }, + { "docs/add2.md", new MockFileData("# New Document 2") }, + { "docs/add3.md", new MockFileData("# New Document 3") }, + { "docs/skip.md", new MockFileData("# Skipped Document") }, + { "docs/update.md", new MockFileData("# Existing Document") }, + }, + new MockFileSystemOptions { CurrentDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly"), } + ); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var assembleFs = CheckoutsFileSystem.FromWorkingDirectory(fileSystem); - var context = new AssembleContext(config, configurationContext, "dev", collector, assembleFs, null, Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly")); - A.CallTo(() => mockS3Client.ListObjectsV2Async(A._, A._)) - .Returns(new ListObjectsV2Response - { - S3Objects = - [ - new S3Object { Key = "docs/delete.md" }, - new S3Object - { - Key = "docs/skip.md", - ETag = "\"69048c0964c9577a399b138b706a467a\"" - }, // This is the result of CalculateS3ETag - new S3Object - { - Key = "docs/update.md", - ETag = "\"existing-etag\"" - } - ] - }); + var context = new AssembleContext( + config, + configurationContext, + "dev", + collector, + assembleFs, + null, + Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly") + ); + A.CallTo(() => mockS3Client.ListObjectsV2Async(A._, A._)).Returns(new ListObjectsV2Response + { + S3Objects = + [ + new S3Object { Key = "docs/delete.md" }, + new S3Object + { + Key = "docs/skip.md", + ETag = "\"69048c0964c9577a399b138b706a467a\"" + }, // This is the result of CalculateS3ETag + + new S3Object { Key = "docs/update.md", ETag = "\"existing-etag\"" } + ] + }); var planStrategy = new AwsS3SyncPlanStrategy(new LoggerFactory(), mockS3Client, "fake", context); // Act @@ -131,7 +135,9 @@ bool valid else if (plan.TotalSyncRequests <= 1000) validationResult.DeleteThreshold.Should().Be(Math.Max(deleteThreshold, 0.5f)); - validationResult.Valid.Should().Be(valid, $"Delete ratio is {validationResult.DeleteRatio} when maximum is {validationResult.DeleteThreshold}"); + validationResult.Valid + .Should() + .Be(valid, $"Delete ratio is {validationResult.DeleteRatio} when maximum is {validationResult.DeleteThreshold}"); } [Theory] @@ -169,11 +175,17 @@ bool valid else if (plan.TotalSyncRequests <= 1000) validationResult.DeleteThreshold.Should().Be(Math.Max(deleteThreshold, 0.5f)); - validationResult.Valid.Should().Be(valid, $"Delete ratio is {validationResult.DeleteRatio} when maximum is {validationResult.DeleteThreshold}"); + validationResult.Valid + .Should() + .Be(valid, $"Delete ratio is {validationResult.DeleteRatio} when maximum is {validationResult.DeleteThreshold}"); } private static async Task<(DocsSyncPlanValidator validator, AwsS3SyncPlanStrategy planStrategy, SyncPlan plan)> SetupS3SyncContextSetup( - int localFiles, int remoteFiles, float? deleteThreshold = null, string etag = "etag") + int localFiles, + int remoteFiles, + float? deleteThreshold = null, + string etag = "etag" + ) { // Arrange IReadOnlyCollection diagnosticsOutputs = []; @@ -189,23 +201,26 @@ bool valid var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var assembleFs2 = CheckoutsFileSystem.FromWorkingDirectory(fileSystem); - var context = new AssembleContext(config, configurationContext, "dev", collector, assembleFs2, null, Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly")); + var context = new AssembleContext( + config, + configurationContext, + "dev", + collector, + assembleFs2, + null, + Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly") + ); var s3Objects = new List(); foreach (var i in Enumerable.Range(0, remoteFiles)) { - s3Objects.Add(new S3Object - { - Key = $"docs/file-{i}.md", - ETag = etag - }); + s3Objects.Add(new S3Object { Key = $"docs/file-{i}.md", ETag = etag }); } - A.CallTo(() => mockS3Client.ListObjectsV2Async(A._, A._)) - .Returns(new ListObjectsV2Response - { - S3Objects = s3Objects - }); + A.CallTo(() => mockS3Client.ListObjectsV2Async(A._, A._)).Returns(new ListObjectsV2Response + { + S3Objects = s3Objects + }); var mockEtagCalculator = A.Fake(); A.CallTo(() => mockEtagCalculator.CalculateS3ETag(A._, A._)).Returns("etag"); @@ -225,17 +240,17 @@ public async Task TestApply() var collector = new DiagnosticsCollector(diagnosticsOutputs); var moxS3Client = A.Fake(); var moxTransferUtility = A.Fake(); - var fileSystem = new MockFileSystem(new Dictionary - { - { "docs/add1.md", new MockFileData("# New Document 1") }, - { "docs/add2.md", new MockFileData("# New Document 2") }, - { "docs/add3.md", new MockFileData("# New Document 3") }, - { "docs/skip.md", new MockFileData("# Skipped Document") }, - { "docs/update.md", new MockFileData("# Existing Document") }, - }, new MockFileSystemOptions - { - CurrentDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly"), - }); + var fileSystem = new MockFileSystem( + new Dictionary + { + { "docs/add1.md", new MockFileData("# New Document 1") }, + { "docs/add2.md", new MockFileData("# New Document 2") }, + { "docs/add3.md", new MockFileData("# New Document 3") }, + { "docs/skip.md", new MockFileData("# Skipped Document") }, + { "docs/update.md", new MockFileData("# Existing Document") }, + }, + new MockFileSystemOptions { CurrentDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly"), } + ); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var checkoutDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly"); @@ -249,35 +264,28 @@ public async Task TestApply() TotalRemoteFiles = 0, TotalSourceFiles = 5, TotalSyncRequests = 6, - AddRequests = [ + AddRequests = + [ new AddRequest { LocalPath = "docs/add1.md", DestinationPath = "docs/add1.md" }, new AddRequest { LocalPath = "docs/add2.md", DestinationPath = "docs/add2.md" }, new AddRequest { LocalPath = "docs/add3.md", DestinationPath = "docs/add3.md" } ], - UpdateRequests = [ - new UpdateRequest - { LocalPath = "docs/update.md", DestinationPath = "docs/update.md" } - ], - SkipRequests = [ - new SkipRequest - { LocalPath = "docs/skip.md", DestinationPath = "docs/skip.md" } - ], - DeleteRequests = [ - new DeleteRequest - { DestinationPath = "docs/delete.md" } - ] + UpdateRequests = [new UpdateRequest { LocalPath = "docs/update.md", DestinationPath = "docs/update.md" }], + SkipRequests = [new SkipRequest { LocalPath = "docs/skip.md", DestinationPath = "docs/skip.md" }], + DeleteRequests = [new DeleteRequest { DestinationPath = "docs/delete.md" }] }; - A.CallTo(() => moxS3Client.DeleteObjectsAsync(A._, A._)) - .Returns(new DeleteObjectsResponse - { - HttpStatusCode = System.Net.HttpStatusCode.OK - }); + A.CallTo(() => moxS3Client.DeleteObjectsAsync(A._, A._)).Returns(new DeleteObjectsResponse + { + HttpStatusCode = System.Net.HttpStatusCode.OK + }); var transferredFiles = Array.Empty(); - A.CallTo(() => moxTransferUtility.UploadDirectoryAsync(A._, A._)) - .Invokes((TransferUtilityUploadDirectoryRequest request, Cancel _) => - { - transferredFiles = fileSystem.Directory.GetFiles(request.Directory, request.SearchPattern, request.SearchOption); - }); + A.CallTo(() => moxTransferUtility.UploadDirectoryAsync(A._, A._)).Invokes(( + TransferUtilityUploadDirectoryRequest request, + Cancel _ + ) => + { + transferredFiles = fileSystem.Directory.GetFiles(request.Directory, request.SearchPattern, request.SearchOption); + }); // Configure OpenTelemetry to capture telemetry var exportedActivities = new List(); @@ -295,11 +303,11 @@ public async Task TestApply() transferredFiles.Length.Should().Be(4); // 3 add requests + 1 update request transferredFiles.Should().NotContain("docs/skip.md"); - A.CallTo(() => moxS3Client.DeleteObjectsAsync(A._, A._)) - .MustHaveHappenedOnceExactly(); + A.CallTo(() => moxS3Client.DeleteObjectsAsync(A._, A._)).MustHaveHappenedOnceExactly(); - A.CallTo(() => moxTransferUtility.UploadDirectoryAsync(A._, A._)) - .MustHaveHappenedOnceExactly(); + A.CallTo( + () => moxTransferUtility.UploadDirectoryAsync(A._, A._) + ).MustHaveHappenedOnceExactly(); // Assert - Telemetry spans are created exportedActivities.Should().Contain(a => a.DisplayName == "sync apply"); @@ -309,10 +317,20 @@ public async Task TestApply() // Assert - Telemetry tags contain correct aggregate counts var syncActivity = exportedActivities.First(a => a.DisplayName == "sync apply"); var tagObjects = syncActivity.TagObjects.ToList(); - tagObjects.Should().Contain(t => t.Key == "docs.sync.files.added" && Convert.ToInt64(t.Value, System.Globalization.CultureInfo.InvariantCulture) == 3); - tagObjects.Should().Contain(t => t.Key == "docs.sync.files.updated" && Convert.ToInt64(t.Value, System.Globalization.CultureInfo.InvariantCulture) == 1); - tagObjects.Should().Contain(t => t.Key == "docs.sync.files.deleted" && Convert.ToInt64(t.Value, System.Globalization.CultureInfo.InvariantCulture) == 1); - tagObjects.Should().Contain(t => t.Key == "docs.sync.files.skipped" && Convert.ToInt64(t.Value, System.Globalization.CultureInfo.InvariantCulture) == 1); - tagObjects.Should().Contain(t => t.Key == "docs.sync.files.total" && Convert.ToInt64(t.Value, System.Globalization.CultureInfo.InvariantCulture) == 6); + tagObjects.Should().Contain( + t => t.Key == "docs.sync.files.added" && Convert.ToInt64(t.Value, System.Globalization.CultureInfo.InvariantCulture) == 3 + ); + tagObjects.Should().Contain( + t => t.Key == "docs.sync.files.updated" && Convert.ToInt64(t.Value, System.Globalization.CultureInfo.InvariantCulture) == 1 + ); + tagObjects.Should().Contain( + t => t.Key == "docs.sync.files.deleted" && Convert.ToInt64(t.Value, System.Globalization.CultureInfo.InvariantCulture) == 1 + ); + tagObjects.Should().Contain( + t => t.Key == "docs.sync.files.skipped" && Convert.ToInt64(t.Value, System.Globalization.CultureInfo.InvariantCulture) == 1 + ); + tagObjects.Should().Contain( + t => t.Key == "docs.sync.files.total" && Convert.ToInt64(t.Value, System.Globalization.CultureInfo.InvariantCulture) == 6 + ); } } diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/IncrementalDeployRoundTripTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/IncrementalDeployRoundTripTests.cs index b48d0f139c..a2164ccf62 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/IncrementalDeployRoundTripTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/IncrementalDeployRoundTripTests.cs @@ -83,17 +83,21 @@ public async Task CodexRoundTrip() /// The delete ratio (1/6 ≈ 17 %) is below the enforced 0.8 floor for small sync sets, so /// deleteThreshold: 1.0f is passed to allow any deletion ratio. /// - private static (MockFileSystem fs, IAmazonS3 s3, ITransferUtility xfer, ICoreService gh, IncrementalDeployService svc) - Arrange(string outputDir) + private static (MockFileSystem fs, IAmazonS3 s3, ITransferUtility xfer, ICoreService gh, IncrementalDeployService svc) Arrange( + string outputDir + ) { - var fs = new MockFileSystem(new Dictionary - { - { Path.Join(outputDir, "docs/add1.md"), new MockFileData("# New Document 1") }, - { Path.Join(outputDir, "docs/add2.md"), new MockFileData("# New Document 2") }, - { Path.Join(outputDir, "docs/add3.md"), new MockFileData("# New Document 3") }, - { Path.Join(outputDir, "docs/skip.md"), new MockFileData("# Skipped Document") }, - { Path.Join(outputDir, "docs/update.md"), new MockFileData("# Existing Document") }, - }, new MockFileSystemOptions { CurrentDirectory = outputDir }); + var fs = new MockFileSystem( + new Dictionary + { + { Path.Join(outputDir, "docs/add1.md"), new MockFileData("# New Document 1") }, + { Path.Join(outputDir, "docs/add2.md"), new MockFileData("# New Document 2") }, + { Path.Join(outputDir, "docs/add3.md"), new MockFileData("# New Document 3") }, + { Path.Join(outputDir, "docs/skip.md"), new MockFileData("# Skipped Document") }, + { Path.Join(outputDir, "docs/update.md"), new MockFileData("# Existing Document") }, + }, + new MockFileSystemOptions { CurrentDirectory = outputDir } + ); var s3 = A.Fake(); var xfer = A.Fake(); @@ -102,24 +106,23 @@ private static (MockFileSystem fs, IAmazonS3 s3, ITransferUtility xfer, ICoreSer // Mocked ETag calculator: skip.md returns SkipETag (matches remote → skip); // all other files return AnyOtherETag (remote has "stale-etag" → update). var etagCalculator = A.Fake(); - A.CallTo(() => etagCalculator.CalculateS3ETag(A.That.EndsWith("skip.md"), A._)) - .Returns(SkipETag); - A.CallTo(() => etagCalculator.CalculateS3ETag(A.That.Not.EndsWith("skip.md"), A._)) - .Returns(AnyOtherETag); - - A.CallTo(() => s3.ListObjectsV2Async(A._, A._)) - .Returns(new ListObjectsV2Response - { - S3Objects = - [ - new S3Object { Key = "docs/delete.md" }, - new S3Object { Key = "docs/skip.md", ETag = $"\"{SkipETag}\"" }, - new S3Object { Key = "docs/update.md", ETag = "\"stale-etag\"" }, - ] - }); + A.CallTo(() => etagCalculator.CalculateS3ETag(A.That.EndsWith("skip.md"), A._)).Returns(SkipETag); + A.CallTo(() => etagCalculator.CalculateS3ETag(A.That.Not.EndsWith("skip.md"), A._)).Returns(AnyOtherETag); - A.CallTo(() => s3.DeleteObjectsAsync(A._, A._)) - .Returns(new DeleteObjectsResponse { HttpStatusCode = System.Net.HttpStatusCode.OK }); + A.CallTo(() => s3.ListObjectsV2Async(A._, A._)).Returns(new ListObjectsV2Response + { + S3Objects = + [ + new S3Object { Key = "docs/delete.md" }, + new S3Object { Key = "docs/skip.md", ETag = $"\"{SkipETag}\"" }, + new S3Object { Key = "docs/update.md", ETag = "\"stale-etag\"" }, + ] + }); + + A.CallTo(() => s3.DeleteObjectsAsync(A._, A._)).Returns(new DeleteObjectsResponse + { + HttpStatusCode = System.Net.HttpStatusCode.OK + }); var svc = new IncrementalDeployService(new LoggerFactory(), gh, s3, xfer, etagCalculator); return (fs, s3, xfer, gh, svc); @@ -132,22 +135,26 @@ private static async Task RunRoundTrip( ICoreService gh, IncrementalDeployService svc, IDocsSyncContext context, - string outputDir) + string outputDir + ) { // Capture the files passed to the upload call var transferredFiles = Array.Empty(); - A.CallTo(() => xfer.UploadDirectoryAsync(A._, A._)) - .Invokes((TransferUtilityUploadDirectoryRequest request, Cancel _) => - { - transferredFiles = fs.Directory.GetFiles(request.Directory, request.SearchPattern, request.SearchOption); - }); + A.CallTo(() => xfer.UploadDirectoryAsync(A._, A._)).Invokes(( + TransferUtilityUploadDirectoryRequest request, + Cancel _ + ) => + { + transferredFiles = fs.Directory.GetFiles(request.Directory, request.SearchPattern, request.SearchOption); + }); var planPath = Path.Join(outputDir, "sync-plan.json"); // Act — Plan // deleteThreshold: 1.0 permits any delete ratio (needed because the validator // enforces a 0.8 floor for small sync sets where TotalSyncRequests < 100) - var planOk = await svc.Plan(context.Collector, context, "fake-bucket", planPath, deleteThreshold: 1.0f, excludePatterns: [], Cancel.None); + var planOk = + await svc.Plan(context.Collector, context, "fake-bucket", planPath, deleteThreshold: 1.0f, excludePatterns: [], Cancel.None); planOk.Should().BeTrue("plan should succeed with valid file mix"); fs.File.Exists(planPath).Should().BeTrue("plan JSON must be written to the mock filesystem"); @@ -159,19 +166,18 @@ private static async Task RunRoundTrip( A.CallTo(() => gh.SetOutputAsync("plan-valid", "true")).MustHaveHappenedOnceExactly(); // Assert — uploads: 3 adds + 1 update; skip.md and remote-only delete.md not uploaded - transferredFiles.Select(Path.GetFileName).Should() - .BeEquivalentTo(["add1.md", "add2.md", "add3.md", "update.md"], - "skip.md is unchanged (ETag matches) so it is not re-uploaded"); + transferredFiles.Select(Path.GetFileName) + .Should() + .BeEquivalentTo(["add1.md", "add2.md", "add3.md", "update.md"], "skip.md is unchanged (ETag matches) so it is not re-uploaded"); // Assert — deletes: exactly one S3 delete call for docs/delete.md - A.CallTo(() => s3.DeleteObjectsAsync( - A.That.Matches(r => r.Objects.Any(o => o.Key == "docs/delete.md")), - A._)) - .MustHaveHappenedOnceExactly(); + A.CallTo( + () => + s3.DeleteObjectsAsync(A.That.Matches(r => r.Objects.Any(o => o.Key == "docs/delete.md")), A._) + ).MustHaveHappenedOnceExactly(); // Assert — uploads called once - A.CallTo(() => xfer.UploadDirectoryAsync(A._, A._)) - .MustHaveHappenedOnceExactly(); + A.CallTo(() => xfer.UploadDirectoryAsync(A._, A._)).MustHaveHappenedOnceExactly(); } } @@ -195,10 +201,10 @@ public async Task ExcludedRemoteObjectsAreNotDeleted() var outputDir = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "codex", "docs"); // Local build output: one regular page, nothing under _preview/ - var fs = new MockFileSystem(new Dictionary - { - { Path.Join(outputDir, "docs/page.md"), new MockFileData("# Page") }, - }, new MockFileSystemOptions { CurrentDirectory = outputDir }); + var fs = new MockFileSystem( + new Dictionary { { Path.Join(outputDir, "docs/page.md"), new MockFileData("# Page") }, }, + new MockFileSystemOptions { CurrentDirectory = outputDir } + ); var s3 = A.Fake(); var xfer = A.Fake(); @@ -208,20 +214,21 @@ public async Task ExcludedRemoteObjectsAreNotDeleted() A.CallTo(() => etagCalculator.CalculateS3ETag(A._, A._)).Returns(AnyOtherETag); // Remote has both a regular page (stale) and a preview object that lives alongside codex output - A.CallTo(() => s3.ListObjectsV2Async(A._, A._)) - .Returns(new ListObjectsV2Response - { - S3Objects = - [ - new S3Object { Key = "docs/page.md", ETag = "\"stale-etag\"" }, - new S3Object { Key = "_preview/pr-42/index.html" }, - new S3Object { Key = "403/index.html" }, - new S3Object { Key = "404/index.html" }, - ] - }); - - A.CallTo(() => s3.DeleteObjectsAsync(A._, A._)) - .Returns(new DeleteObjectsResponse { HttpStatusCode = System.Net.HttpStatusCode.OK }); + A.CallTo(() => s3.ListObjectsV2Async(A._, A._)).Returns(new ListObjectsV2Response + { + S3Objects = + [ + new S3Object { Key = "docs/page.md", ETag = "\"stale-etag\"" }, + new S3Object { Key = "_preview/pr-42/index.html" }, + new S3Object { Key = "403/index.html" }, + new S3Object { Key = "404/index.html" }, + ] + }); + + A.CallTo(() => s3.DeleteObjectsAsync(A._, A._)).Returns(new DeleteObjectsResponse + { + HttpStatusCode = System.Net.HttpStatusCode.OK + }); var svc = new IncrementalDeployService(new LoggerFactory(), gh, s3, xfer, etagCalculator); var collector = new DiagnosticsCollector([]); @@ -231,14 +238,16 @@ public async Task ExcludedRemoteObjectsAreNotDeleted() var context = new CodexContext(codexConfig, configFile, collector, codexFs2, null, outputDir); var planPath = Path.Join(outputDir, "sync-plan.json"); - var planOk = await svc.Plan( - context.Collector, - context, - "fake-bucket", - planPath, - deleteThreshold: 1.0f, - excludePatterns: ["_preview/*", "403/*", "404/*"], - Cancel.None); + var planOk = + await svc.Plan( + context.Collector, + context, + "fake-bucket", + planPath, + deleteThreshold: 1.0f, + excludePatterns: ["_preview/*", "403/*", "404/*"], + Cancel.None + ); planOk.Should().BeTrue("plan should succeed"); var applyOk = await svc.Apply(context.Collector, context, "fake-bucket", planPath, Cancel.None); @@ -247,27 +256,46 @@ public async Task ExcludedRemoteObjectsAreNotDeleted() // The plan must not contain deletions for any excluded key var planJson = fs.File.ReadAllText(planPath); var plan = SyncPlan.Deserialize(planJson); - plan.DeleteRequests.Should().NotContain(r => r.DestinationPath.StartsWith("_preview/", StringComparison.Ordinal), - "excluded _preview/* objects must not be queued for deletion"); - plan.DeleteRequests.Should().NotContain(r => r.DestinationPath.StartsWith("403/", StringComparison.Ordinal), - "excluded 403/* objects must not be queued for deletion"); - plan.DeleteRequests.Should().NotContain(r => r.DestinationPath.StartsWith("404/", StringComparison.Ordinal), - "excluded 404/* objects must not be queued for deletion"); + plan.DeleteRequests + .Should() + .NotContain( + r => r.DestinationPath.StartsWith("_preview/", StringComparison.Ordinal), + "excluded _preview/* objects must not be queued for deletion" + ); + plan.DeleteRequests + .Should() + .NotContain( + r => r.DestinationPath.StartsWith("403/", StringComparison.Ordinal), + "excluded 403/* objects must not be queued for deletion" + ); + plan.DeleteRequests + .Should() + .NotContain( + r => r.DestinationPath.StartsWith("404/", StringComparison.Ordinal), + "excluded 404/* objects must not be queued for deletion" + ); // docs/page.md is not excluded so it should be an update (stale ETag) plan.UpdateRequests.Should().Contain(r => r.DestinationPath == "docs/page.md"); // No S3 delete calls should include excluded prefixes - A.CallTo(() => s3.DeleteObjectsAsync( - A.That.Matches(r => r.Objects.Any(o => - o.Key.StartsWith("_preview/", StringComparison.Ordinal) || - o.Key.StartsWith("403/", StringComparison.Ordinal) || - o.Key.StartsWith("404/", StringComparison.Ordinal))), - A._)) - .MustNotHaveHappened(); + A.CallTo( + () => + s3.DeleteObjectsAsync( + A.That.Matches( + r => + r.Objects.Any( + o => + o.Key.StartsWith("_preview/", StringComparison.Ordinal) || + o.Key.StartsWith("403/", StringComparison.Ordinal) || + o.Key.StartsWith("404/", StringComparison.Ordinal) + ) + ), + A._ + ) + ).MustNotHaveHappened(); // Excluded patterns are recorded in the plan file - plan.ExcludePatterns.Should().BeEquivalentTo(["_preview/*", "403/*", "404/*"], - "plan must record which patterns were excluded"); + plan.ExcludePatterns.Should().BeEquivalentTo(["_preview/*", "403/*", "404/*"], "plan must record which patterns were excluded"); } } diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs index 6b0050e00b..0f4f148728 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs @@ -47,8 +47,7 @@ public async Task AssertRealNavigation() var collector = new TestDiagnosticsCollector(TestContext.Current.TestOutputHelper); var fs = new FileSystem(); var assembleFs = CheckoutsFileSystem.FromWorkingDirectory(fs); - var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, "dev", collector, - assembleFs); + var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, "dev", collector, assembleFs); var logFactory = new TestLoggerFactory(TestContext.Current.TestOutputHelper); var cloner = new AssemblerRepositorySourcer(logFactory, assembleContext); var checkoutResult = cloner.GetAll(); @@ -59,7 +58,8 @@ public async Task AssertRealNavigation() throw new Exception("No checkouts found"); var ctx = TestContext.Current.CancellationToken; - var assembleSources = await AssembleSources.AssembleAsync(logFactory, assembleContext, checkouts, configurationContext, new HashSet(), ctx); + var assembleSources = + await AssembleSources.AssembleAsync(logFactory, assembleContext, checkouts, configurationContext, new HashSet(), ctx); var navigationFileInfo = configurationContext.ConfigurationFileProvider.NavigationFile; var siteNavigationFile = SiteNavigationFile.Deserialize(await fs.File.ReadAllTextAsync(navigationFileInfo.FullName, ctx)); @@ -79,12 +79,15 @@ public async Task AssertRealNavigation() root.Parent.Should().BeOfType(); }*/ - var slice = _TocTree.Create(NavigationRenderModel.Create( - tree: navigation, - topLevelItems: navigation.TopLevelItems, - isUsingNavigationDropdown: true, - isPrimaryNavEnabled: true, - isGlobalAssemblyBuild: true)); + var slice = _TocTree.Create( + NavigationRenderModel.Create( + tree: navigation, + topLevelItems: navigation.TopLevelItems, + isUsingNavigationDropdown: true, + isPrimaryNavEnabled: true, + isGlobalAssemblyBuild: true + ) + ); var html = await slice.RenderAsync(cancellationToken: ctx); var context = BrowsingContext.New(); var document = await context.OpenAsync(req => req.Content(html), ctx); @@ -113,11 +116,9 @@ public async Task AssertRealNavigation() await collector.StopAsync(TestContext.Current.CancellationToken); - collector.Errors.Should().Be(0); } - private static void RecurseNav(INodeNavigationItem navigation) { foreach (var nav in navigation.NavigationItems) @@ -157,7 +158,6 @@ private static IEnumerable GetAllNavigationUrls(INavigationItem item) yield return url; } - /// public ValueTask DisposeAsync() { diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs index 61f5c36780..b0206f3e75 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs @@ -47,8 +47,7 @@ public async Task AssertRealNavigation() var collector = new TestDiagnosticsCollector(TestContext.Current.TestOutputHelper); var fs = new FileSystem(); var assembleFs = CheckoutsFileSystem.FromWorkingDirectory(fs); - var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, "dev", collector, - assembleFs); + var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, "dev", collector, assembleFs); var logFactory = new TestLoggerFactory(TestContext.Current.TestOutputHelper); var cloner = new AssemblerRepositorySourcer(logFactory, assembleContext); var checkoutResult = cloner.GetAll(); @@ -59,7 +58,8 @@ public async Task AssertRealNavigation() throw new Exception("No checkouts found"); var ctx = TestContext.Current.CancellationToken; - var assembleSources = await AssembleSources.AssembleAsync(logFactory, assembleContext, checkouts, configurationContext, new HashSet(), ctx); + var assembleSources = + await AssembleSources.AssembleAsync(logFactory, assembleContext, checkouts, configurationContext, new HashSet(), ctx); var navigationFileInfo = configurationContext.ConfigurationFileProvider.NavigationFile; var siteNavigationFile = SiteNavigationFile.Deserialize(await fs.File.ReadAllTextAsync(navigationFileInfo.FullName, ctx)); @@ -68,10 +68,20 @@ public async Task AssertRealNavigation() var allowedRoots = navigation.TopLevelItems.Concat([navigation]).ToHashSet(); foreach (var item in ((INavigationTraversable)navigation).YieldAll()) - item.NavigationRoot.Should().BeOneOf(allowedRoots, "Navigation for '{0}' has bad root '{1}'", item.Url, item.NavigationRoot.Identifier); + item.NavigationRoot + .Should() + .BeOneOf(allowedRoots, "Navigation for '{0}' has bad root '{1}'", item.Url, item.NavigationRoot.Identifier); foreach (var item in ((INavigationTraversable)navigation).NavigationIndexedByOrder.Values) - item.NavigationRoot.Should().BeOneOf(allowedRoots, "Navigation for '{0}' has bad root '{1}' indexed by order {2}", item.Url, item.NavigationRoot.Identifier, item.NavigationIndex); + item.NavigationRoot + .Should() + .BeOneOf( + allowedRoots, + "Navigation for '{0}' has bad root '{1}' indexed by order {2}", + item.Url, + item.NavigationRoot.Identifier, + item.NavigationIndex + ); collector.Errors.Should().Be(0); } diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchBootstrapFixture.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchBootstrapFixture.cs index dc6c3d819d..f54e5c2e50 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchBootstrapFixture.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchBootstrapFixture.cs @@ -38,7 +38,8 @@ public async ValueTask InitializeAsync() { // Wait for AssemblerServe to be ready (it hosts the embedded Lambda API) Console.WriteLine("Waiting for AssemblerServe (with embedded API) to become healthy..."); - await fixture.DistributedApplication.ResourceNotifications + await fixture.DistributedApplication + .ResourceNotifications .WaitForResourceHealthyAsync(ResourceNames.AssemblerServe, cancellationToken: TestContext.Current.CancellationToken) .WaitAsync(TimeSpan.FromMinutes(2), TestContext.Current.CancellationToken); @@ -67,26 +68,25 @@ await fixture.DistributedApplication.ResourceNotifications // The indexer always has WithExplicitStart(), so we must manually start it // Get the ResourceLoggerService to send the start command - fixture.DistributedApplication.Services - .GetRequiredService(); + fixture.DistributedApplication.Services.GetRequiredService(); // Get the resource notification service to find the resource - fixture.DistributedApplication.Services - .GetRequiredService(); + fixture.DistributedApplication.Services.GetRequiredService(); // Wait for the resource to be available - var resourceEvent = await fixture.DistributedApplication.ResourceNotifications - .WaitForResourceAsync(ResourceNames.ElasticsearchIngest, _ => true, TestContext.Current.CancellationToken) - .WaitAsync(TimeSpan.FromMinutes(1), TestContext.Current.CancellationToken); + var resourceEvent = + await fixture.DistributedApplication + .ResourceNotifications + .WaitForResourceAsync(ResourceNames.ElasticsearchIngest, _ => true, TestContext.Current.CancellationToken) + .WaitAsync(TimeSpan.FromMinutes(1), TestContext.Current.CancellationToken); // Get the resource instance var resource = resourceEvent.Resource; // Execute the start command using ResourceCommandAnnotation - var startCommand = resource.Annotations.OfType() - .FirstOrDefault(a => a.Name == "resource-start"); - var logger = fixture.DistributedApplication.Services.GetService>() - ?? NullLoggerFactory.Instance.CreateLogger(); + var startCommand = resource.Annotations.OfType().FirstOrDefault(a => a.Name == "resource-start"); + var logger = fixture.DistributedApplication.Services.GetService>() ?? + NullLoggerFactory.Instance.CreateLogger(); if (startCommand != null) { @@ -115,10 +115,15 @@ await fixture.DistributedApplication.ResourceNotifications Console.WriteLine("Waiting for indexer to complete..."); // Wait for the indexer to complete - _ = await fixture.DistributedApplication.ResourceNotifications - .WaitForResourceAsync(ResourceNames.ElasticsearchIngest, KnownResourceStates.TerminalStates, - cancellationToken: TestContext.Current.CancellationToken) - .WaitAsync(TimeSpan.FromMinutes(10), TestContext.Current.CancellationToken); + _ = + await fixture.DistributedApplication + .ResourceNotifications + .WaitForResourceAsync( + ResourceNames.ElasticsearchIngest, + KnownResourceStates.TerminalStates, + cancellationToken: TestContext.Current.CancellationToken + ) + .WaitAsync(TimeSpan.FromMinutes(10), TestContext.Current.CancellationToken); Console.WriteLine("Elasticsearch indexer reached terminal state. Validating exit code..."); @@ -130,8 +135,7 @@ await fixture.DistributedApplication.ResourceNotifications catch (Exception e) { Console.WriteLine($"Failed to initialize test: {e.Message}"); - Console.WriteLine(string.Join(Environment.NewLine, - fixture.InMemoryLogger.RecordedLogs.Reverse().Take(50).Reverse())); + Console.WriteLine(string.Join(Environment.NewLine, fixture.InMemoryLogger.RecordedLogs.Reverse().Take(50).Reverse())); throw; } } @@ -195,8 +199,7 @@ private async ValueTask IsIndexingNeeded() private async ValueTask ValidateResourceExitCode(string resourceName) { - var eventResource = await fixture.DistributedApplication.ResourceNotifications - .WaitForResourceAsync(resourceName, _ => true); + var eventResource = await fixture.DistributedApplication.ResourceNotifications.WaitForResourceAsync(resourceName, _ => true); var id = eventResource.ResourceId; if (!fixture.DistributedApplication.ResourceNotifications.TryGetCurrentState(id, out var state)) @@ -204,10 +207,10 @@ private async ValueTask ValidateResourceExitCode(string resourceName) if (state.Snapshot.ExitCode is not 0) { - var recentLogs = string.Join(Environment.NewLine, - fixture.InMemoryLogger.RecordedLogs.Reverse().Take(100).Reverse()); + var recentLogs = string.Join(Environment.NewLine, fixture.InMemoryLogger.RecordedLogs.Reverse().Take(100).Reverse()); throw new Exception( - $"Exit code should be 0 for {resourceName}, but was {state.Snapshot.ExitCode}. Recent logs:{Environment.NewLine}{recentLogs}"); + $"Exit code should be 0 for {resourceName}, but was {state.Snapshot.ExitCode}. Recent logs:{Environment.NewLine}{recentLogs}" + ); } Console.WriteLine($"{resourceName} completed with exit code 0"); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchIntegrationTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchIntegrationTests.cs index a114f798c9..5c70f663e4 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchIntegrationTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchIntegrationTests.cs @@ -22,23 +22,31 @@ public class SearchIntegrationTests(SearchBootstrapFixture searchFixture, ITestO /// Format: (query, expectedFirstResultUrl) /// Note: These URLs reflect the actual search results from the indexed documentation. /// - public static TheoryData SearchQueryTestCases => new() - { - //TODO these results reflect todays result, we still have some work to do to improve the relevance of the search results - - // Elasticsearch specific queries - { "elasticsearch getting started", "/docs/reference/elasticsearch/clients/java/getting-started" }, - { "apm", "/docs/reference/apm/observability/apm" }, - { "kibana dashboard", "/docs/reference/beats/auditbeat/configuration-dashboards" }, - - // .NET specific queries (testing dotnet -> net replacement) - { "dotnet client", "/docs/reference/elasticsearch/clients/dotnet/using-net-client" }, - { ".net apm agent", "/docs/reference/apm/agents/dotnet" }, - - // General queries - { "machine learning", "/docs/reference/machine-learning" }, - { "ingest pipeline", "/docs/reference/beats/metricbeat/configuring-ingest-node" }, - }; + public static TheoryData SearchQueryTestCases => + new() + { + //TODO these results reflect todays result, we still have some work to do to improve the relevance of the search results + + // Elasticsearch specific queries + { + "elasticsearch getting started", + "/docs/reference/elasticsearch/clients/java/getting-started" + }, + { "apm", "/docs/reference/apm/observability/apm" }, + { "kibana dashboard", "/docs/reference/beats/auditbeat/configuration-dashboards" }, + // .NET specific queries (testing dotnet -> net replacement) + { + "dotnet client", + "/docs/reference/elasticsearch/clients/dotnet/using-net-client" + }, + { ".net apm agent", "/docs/reference/apm/agents/dotnet" }, + // General queries + { + "machine learning", + "/docs/reference/machine-learning" + }, + { "ingest pipeline", "/docs/reference/beats/metricbeat/configuring-ingest-node" }, + }; [Theory] [MemberData(nameof(SearchQueryTestCases))] @@ -50,12 +58,17 @@ public async Task SearchEndpointReturnsExpectedFirstResult(string query, string searchFixture.HttpClient.Should().NotBeNull("HTTP client should be initialized"); // Act - var response = await searchFixture.HttpClient.GetAsync($"/docs/_api/v1/search?q={Uri.EscapeDataString(query)}&page=1", TestContext.Current.CancellationToken); + var response = + await searchFixture.HttpClient.GetAsync( + $"/docs/_api/v1/search?q={Uri.EscapeDataString(query)}&page=1", + TestContext.Current.CancellationToken + ); // Assert - Response should be successful response.EnsureSuccessStatusCode(); - var searchResponse = await response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); + var searchResponse = + await response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); searchResponse.Should().NotBeNull("Search response should be deserialized"); // Log results for debugging @@ -77,8 +90,10 @@ public async Task SearchEndpointReturnsExpectedFirstResult(string query, string // Assert - First result should match expected URL var actualFirstResultUrl = searchResponse.Results[0].Url; - actualFirstResultUrl.Should().Be(expectedFirstResultUrl, - $"First result for query '{query}' should be the expected documentation page"); + actualFirstResultUrl.Should().Be( + expectedFirstResultUrl, + $"First result for query '{query}' should be the expected documentation page" + ); } [Fact] @@ -91,14 +106,24 @@ public async Task SearchEndpointWithPaginationReturnsCorrectPage() const string query = "elasticsearch"; // Act - Get first page - var page1Response = await searchFixture.HttpClient.GetAsync($"/docs/_api/v1/search?q={Uri.EscapeDataString(query)}&page=1", TestContext.Current.CancellationToken); + var page1Response = + await searchFixture.HttpClient.GetAsync( + $"/docs/_api/v1/search?q={Uri.EscapeDataString(query)}&page=1", + TestContext.Current.CancellationToken + ); page1Response.EnsureSuccessStatusCode(); - var page1Data = await page1Response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); + var page1Data = + await page1Response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); // Act - Get second page - var page2Response = await searchFixture.HttpClient.GetAsync($"/docs/_api/v1/search?q={Uri.EscapeDataString(query)}&page=2", TestContext.Current.CancellationToken); + var page2Response = + await searchFixture.HttpClient.GetAsync( + $"/docs/_api/v1/search?q={Uri.EscapeDataString(query)}&page=2", + TestContext.Current.CancellationToken + ); page2Response.EnsureSuccessStatusCode(); - var page2Data = await page2Response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); + var page2Data = + await page2Response.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); // Assert page1Data.Should().NotBeNull(); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/ServeStaticTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/ServeStaticTests.cs index cebeeb3afc..db1e576cbb 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/ServeStaticTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/ServeStaticTests.cs @@ -18,7 +18,6 @@ public async Task AssertRequestToRootReturnsData() _ = root.Should().NotBeNullOrEmpty(); } - /// public ValueTask DisposeAsync() { diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs index 3afa2bfdc1..95fd781a23 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs @@ -56,11 +56,7 @@ private Checkout CreateCheckout(IFileSystem fs, Repository repository) : fs.DirectoryInfo.New(fs.Path.Join(CheckoutDirectory.FullName, name)); return new Checkout { - Repository = new Repository - { - Name = name, - Origin = $"elastic/{name}" - }, + Repository = new Repository { Name = name, Origin = $"elastic/{name}" }, HeadReference = Guid.NewGuid().ToString(), Directory = path }; @@ -70,18 +66,23 @@ private Checkout CreateCheckout(IFileSystem fs, Repository repository) { _ = Collector.StartAsync(TestContext.Current.CancellationToken); - var repos = Context.Configuration.AvailableRepositories - .Where(kv => !kv.Value.Skip) - .Select(kv => kv.Value) - .ToArray(); + var repos = Context.Configuration.AvailableRepositories.Where(kv => !kv.Value.Skip).Select(kv => kv.Value).ToArray(); var checkouts = repos.Select(r => CreateCheckout(FileSystem, r)).ToArray(); var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem()); - var assembleSources = await AssembleSources.AssembleAsync( - NullLoggerFactory.Instance, Context, checkouts, configurationContext, ExportOptions.Default, TestContext.Current.CancellationToken - ); + var assembleSources = + await AssembleSources.AssembleAsync( + NullLoggerFactory.Instance, + Context, + checkouts, + configurationContext, + ExportOptions.Default, + TestContext.Current.CancellationToken + ); var navigationFileInfo = configurationContext.ConfigurationFileProvider.NavigationFile; - var siteNavigationFile = SiteNavigationFile.Deserialize(await FileSystem.File.ReadAllTextAsync(navigationFileInfo.FullName, TestContext.Current.CancellationToken)); + var siteNavigationFile = SiteNavigationFile.Deserialize( + await FileSystem.File.ReadAllTextAsync(navigationFileInfo.FullName, TestContext.Current.CancellationToken) + ); var documentationSets = assembleSources.AssembleSets.Values.Select(s => s.DocumentationSet.Navigation).ToArray(); var navigation = new SiteNavigation(siteNavigationFile, Context, documentationSets, Context.Environment.PathPrefix); @@ -102,7 +103,9 @@ public async Task ReadAllPathPrefixes() var context = new AssembleContext(config, configurationContext, "dev", collector, assembleFs2); var navigationFileInfo = configurationContext.ConfigurationFileProvider.NavigationFile; - var siteNavigationFile = SiteNavigationFile.Deserialize(await FileSystem.File.ReadAllTextAsync(navigationFileInfo.FullName, TestContext.Current.CancellationToken)); + var siteNavigationFile = SiteNavigationFile.Deserialize( + await FileSystem.File.ReadAllTextAsync(navigationFileInfo.FullName, TestContext.Current.CancellationToken) + ); var declaredSources = SiteNavigationFile.GetAllDeclaredSources(siteNavigationFile); declaredSources.Should().NotBeEmpty(); @@ -120,7 +123,6 @@ public async Task SiteNavigationNodesContainAllDocumentationSets() navigation.Nodes.Should().ContainKey(new Uri("detection-rules://")); } - [Fact] public async Task ParsesReferences() { @@ -148,8 +150,6 @@ public async Task ParsesReferences() navigation.NavigationItems.Should().NotBeNull(); } - - [Fact] public async Task ParsesSiteNavigation() { @@ -193,14 +193,17 @@ public async Task UriResolving() var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var assembleFs3 = CheckoutsFileSystem.FromWorkingDirectory(fs); var assembleContext = new AssembleContext(config, configurationContext, "prod", collector, assembleFs3); - var repos = assembleContext.Configuration.AvailableRepositories - .Where(kv => !kv.Value.Skip) - .Select(kv => kv.Value) - .ToArray(); + var repos = assembleContext.Configuration.AvailableRepositories.Where(kv => !kv.Value.Skip).Select(kv => kv.Value).ToArray(); var checkouts = repos.Select(r => CreateCheckout(fs, r)).ToArray(); - var assembleSources = await AssembleSources.AssembleAsync( - NullLoggerFactory.Instance, assembleContext, checkouts, configurationContext, ExportOptions.Default, TestContext.Current.CancellationToken - ); + var assembleSources = + await AssembleSources.AssembleAsync( + NullLoggerFactory.Instance, + assembleContext, + checkouts, + configurationContext, + ExportOptions.Default, + TestContext.Current.CancellationToken + ); var uriResolver = assembleSources.UriResolver; diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs index bd6741bde0..8a4535acd1 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/ApiSmokeTests.cs @@ -56,11 +56,13 @@ public async Task SearchEndpoint_ReturnsResults() Assert.NotNull(body); Assert.SkipUnless(body.TotalResults > 0 && body.Results.Count > 0, "search index has no data, skipping result assertions"); _ = body.Results.Should().NotBeEmpty("search for 'elasticsearch' should return results when the index is populated"); - body.Results.Should().AllSatisfy(r => - { - _ = r.Url.Should().NotBeNullOrEmpty(); - _ = r.Title.Should().NotBeNullOrEmpty(); - }); + body.Results + .Should() + .AllSatisfy(r => + { + _ = r.Url.Should().NotBeNullOrEmpty(); + _ = r.Title.Should().NotBeNullOrEmpty(); + }); } [Fact] @@ -69,8 +71,10 @@ public async Task ChangesEndpoint_ReturnsResponse() using var client = fixture.CreateApiClient(); var since = Uri.EscapeDataString("2020-01-01T00:00:00Z"); // The changes endpoint requires open_point_in_time privilege. CI uses a read-only API key that lacks it. - Assert.SkipUnless(string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")), - "Skipping: CI read-only API key lacks open_point_in_time privilege required by the changes endpoint"); + Assert.SkipUnless( + string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")), + "Skipping: CI read-only API key lacks open_point_in_time privilege required by the changes endpoint" + ); var response = await client.GetAsync($"/docs/_api/v1/changes?since={since}", TestContext.Current.CancellationToken); if (!response.IsSuccessStatusCode) diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/McpSmokeTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/McpSmokeTests.cs index 2a33f373ae..1fa0a74e99 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/McpSmokeTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Smoke/McpSmokeTests.cs @@ -42,10 +42,9 @@ public async Task ListTools_ReturnsAtLeastOneTool() new HttpClientTransportOptions { Endpoint = mcpEndpoint }, httpClient, NullLoggerFactory.Instance, - ownsHttpClient: false); - await using var mcpClient = await McpClient.CreateAsync( - transport, - cancellationToken: TestContext.Current.CancellationToken); + ownsHttpClient: false + ); + await using var mcpClient = await McpClient.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken); var tools = await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); _ = tools.Should().NotBeEmpty("the MCP server should expose at least one tool"); } diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/TestHelpers.cs b/tests-integration/Elastic.Documentation.IntegrationTests/TestHelpers.cs index 26952c33a3..72894195ae 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/TestHelpers.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/TestHelpers.cs @@ -25,13 +25,19 @@ public static IConfigurationContext CreateConfigurationContext( ProductsConfiguration? productsConfiguration = null ) { - configurationFileProvider ??= new ConfigurationFileProvider(NullLoggerFactory.Instance, new ConfigurationFileSystem(fileSystem), skipPrivateRepositories: true); + configurationFileProvider ??= + new ConfigurationFileProvider( + NullLoggerFactory.Instance, + new ConfigurationFileSystem(fileSystem), + skipPrivateRepositories: true + ); versionsConfiguration ??= new VersionsConfiguration { VersioningSystems = new Dictionary { { - VersioningSystemId.Stack, new VersioningSystem + VersioningSystemId.Stack, + new VersioningSystem { Id = VersioningSystemId.Stack, Current = new SemVersion(8, 0, 0), @@ -45,7 +51,8 @@ public static IConfigurationContext CreateConfigurationContext( var products = new Dictionary { { - "elasticsearch", new Product + "elasticsearch", + new Product { Id = "elasticsearch", DisplayName = "Elasticsearch", @@ -63,10 +70,7 @@ public static IConfigurationContext CreateConfigurationContext( var search = new SearchConfiguration { Synonyms = [], Rules = [], DiminishTerms = [] }; return new ConfigurationContext { - Endpoints = new DocumentationEndpoints - { - Elasticsearch = ElasticsearchEndpoint.Default, - }, + Endpoints = new DocumentationEndpoints { Elasticsearch = ElasticsearchEndpoint.Default, }, ConfigurationFileProvider = configurationFileProvider, VersionsConfiguration = versionsConfiguration, ProductsConfiguration = productsConfiguration, diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/TestLogger.cs b/tests-integration/Elastic.Documentation.IntegrationTests/TestLogger.cs index c82e6fb711..e07c090cc1 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/TestLogger.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/TestLogger.cs @@ -18,8 +18,13 @@ public void Dispose() { } public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Trace; - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => - output?.WriteLine(formatter(state, exception)); + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) => output?.WriteLine(formatter(state, exception)); } public class TestLoggerFactory(ITestOutputHelper? output) : ILoggerFactory @@ -41,8 +46,9 @@ public void Write(Diagnostic diagnostic) } } -public class TestDiagnosticsCollector(ITestOutputHelper? output) - : DiagnosticsCollector(output != null ? [new TestDiagnosticsOutput(output)] : []) +public class TestDiagnosticsCollector(ITestOutputHelper? output) : DiagnosticsCollector( + output != null ? [new TestDiagnosticsOutput(output)] : [] +) { private readonly List _diagnostics = []; diff --git a/tests-integration/Mcp.Remote.IntegrationTests/CoherenceToolsIntegrationTests.cs b/tests-integration/Mcp.Remote.IntegrationTests/CoherenceToolsIntegrationTests.cs index 98ada54b3b..72841c8bb7 100644 --- a/tests-integration/Mcp.Remote.IntegrationTests/CoherenceToolsIntegrationTests.cs +++ b/tests-integration/Mcp.Remote.IntegrationTests/CoherenceToolsIntegrationTests.cs @@ -24,10 +24,12 @@ public async Task CheckCoherence_ReturnsAnalysis() Assert.SkipUnless(canConnect, "Elasticsearch is not connected"); // Act - var resultJson = await coherenceTools.CheckCoherence( - "elasticsearch security", - limit: 10, - cancellationToken: TestContext.Current.CancellationToken); + var resultJson = + await coherenceTools.CheckCoherence( + "elasticsearch security", + limit: 10, + cancellationToken: TestContext.Current.CancellationToken + ); // Assert Output.WriteLine($"Result: {resultJson}"); @@ -55,10 +57,12 @@ public async Task FindInconsistencies_ReturnsResults() Assert.SkipUnless(canConnect, "Elasticsearch is not connected"); // Act - var resultJson = await coherenceTools.FindInconsistencies( - "authentication", - focusArea: "configuration", - cancellationToken: TestContext.Current.CancellationToken); + var resultJson = + await coherenceTools.FindInconsistencies( + "authentication", + focusArea: "configuration", + cancellationToken: TestContext.Current.CancellationToken + ); // Assert Output.WriteLine($"Result: {resultJson}"); diff --git a/tests-integration/Mcp.Remote.IntegrationTests/DocumentToolsIntegrationTests.cs b/tests-integration/Mcp.Remote.IntegrationTests/DocumentToolsIntegrationTests.cs index 927aeb9022..6955e20efb 100644 --- a/tests-integration/Mcp.Remote.IntegrationTests/DocumentToolsIntegrationTests.cs +++ b/tests-integration/Mcp.Remote.IntegrationTests/DocumentToolsIntegrationTests.cs @@ -24,9 +24,8 @@ public async Task GetDocumentByUrl_ReturnsDocument() Assert.SkipUnless(canConnect, "Elasticsearch is not connected"); // Act - use a URL that is likely to exist - var resultJson = await documentTools.GetDocumentByUrl( - "/docs/reference/elasticsearch", - cancellationToken: TestContext.Current.CancellationToken); + var resultJson = + await documentTools.GetDocumentByUrl("/docs/reference/elasticsearch", cancellationToken: TestContext.Current.CancellationToken); // Assert Output.WriteLine($"Result: {resultJson}"); @@ -59,9 +58,11 @@ public async Task GetDocumentByUrl_NotFound_ReturnsError() Assert.SkipUnless(canConnect, "Elasticsearch is not connected"); // Act - use a URL that should not exist - var resultJson = await documentTools.GetDocumentByUrl( - "/docs/this-document-definitely-does-not-exist-12345", - cancellationToken: TestContext.Current.CancellationToken); + var resultJson = + await documentTools.GetDocumentByUrl( + "/docs/this-document-definitely-does-not-exist-12345", + cancellationToken: TestContext.Current.CancellationToken + ); // Assert Output.WriteLine($"Result: {resultJson}"); @@ -83,9 +84,8 @@ public async Task GetDocumentByUrl_SourceUrlIsGitHubBlobUrlWhenPresent() Assert.SkipUnless(canConnect, "Elasticsearch is not connected"); // Act - var resultJson = await documentTools.GetDocumentByUrl( - "/docs/reference/elasticsearch", - cancellationToken: TestContext.Current.CancellationToken); + var resultJson = + await documentTools.GetDocumentByUrl("/docs/reference/elasticsearch", cancellationToken: TestContext.Current.CancellationToken); if (resultJson.Contains("\"error\"")) Assert.Skip("Test document not found in index"); @@ -109,9 +109,11 @@ public async Task AnalyzeDocumentStructure_ReturnsStructure() Assert.SkipUnless(canConnect, "Elasticsearch is not connected"); // Act - use a URL that is likely to exist - var resultJson = await documentTools.AnalyzeDocumentStructure( - "/docs/reference/elasticsearch", - cancellationToken: TestContext.Current.CancellationToken); + var resultJson = + await documentTools.AnalyzeDocumentStructure( + "/docs/reference/elasticsearch", + cancellationToken: TestContext.Current.CancellationToken + ); // Assert Output.WriteLine($"Result: {resultJson}"); diff --git a/tests-integration/Mcp.Remote.IntegrationTests/McpToolsIntegrationTestsBase.cs b/tests-integration/Mcp.Remote.IntegrationTests/McpToolsIntegrationTestsBase.cs index 2c44ab55b3..9e23edad8d 100644 --- a/tests-integration/Mcp.Remote.IntegrationTests/McpToolsIntegrationTestsBase.cs +++ b/tests-integration/Mcp.Remote.IntegrationTests/McpToolsIntegrationTestsBase.cs @@ -35,9 +35,11 @@ protected void LogDiagnostics(ElasticsearchClientAccessor? clientAccessor) protected async Task LogIndexCount(ElasticsearchClientAccessor clientAccessor, CancellationToken ctx) { var countResponse = await clientAccessor.Client.CountAsync(c => c.Indices(clientAccessor.SearchIndex), ctx); - Output.WriteLine(countResponse.IsValidResponse - ? $"Index document count: {countResponse.Count}" - : $"Index count ERROR: {countResponse.ElasticsearchServerError?.Error?.Reason}"); + Output.WriteLine( + countResponse.IsValidResponse + ? $"Index document count: {countResponse.Count}" + : $"Index count ERROR: {countResponse.ElasticsearchServerError?.Error?.Reason}" + ); } /// @@ -79,7 +81,10 @@ protected async Task LogIndexCount(ElasticsearchClientAccessor clientAccessor, C return (coherenceTools, clientAccessor); } - private static FullSearchService BuildFullSearchAdapter(ElasticsearchClientAccessor clientAccessor, ProductsConfiguration productsConfig) + private static FullSearchService BuildFullSearchAdapter( + ElasticsearchClientAccessor clientAccessor, + ProductsConfiguration productsConfig + ) { var queryConfig = new SearchQueryConfiguration { @@ -89,9 +94,12 @@ private static FullSearchService BuildFullSearchAdapter(ElasticsearchClientAcces SemanticEnabled = true }; var inner = new DefaultSearchService( - clientAccessor.Client, clientAccessor.SearchIndex, queryConfig, + clientAccessor.Client, + clientAccessor.SearchIndex, + queryConfig, NullLogger>.Instance, - productsConfig); + productsConfig + ); return new FullSearchService(inner, productsConfig, NullLogger.Instance); } diff --git a/tests-integration/Mcp.Remote.IntegrationTests/SearchToolsIntegrationTests.cs b/tests-integration/Mcp.Remote.IntegrationTests/SearchToolsIntegrationTests.cs index 207dc6f82a..466c6ccf60 100644 --- a/tests-integration/Mcp.Remote.IntegrationTests/SearchToolsIntegrationTests.cs +++ b/tests-integration/Mcp.Remote.IntegrationTests/SearchToolsIntegrationTests.cs @@ -24,9 +24,8 @@ public async Task SemanticSearch_ReturnsResults() Assert.SkipUnless(canConnect, "Elasticsearch is not connected"); // Act - var resultJson = await searchTools.SemanticSearch( - "elasticsearch getting started", - cancellationToken: TestContext.Current.CancellationToken); + var resultJson = + await searchTools.SemanticSearch("elasticsearch getting started", cancellationToken: TestContext.Current.CancellationToken); // Assert Output.WriteLine($"Result: {resultJson}"); @@ -36,7 +35,9 @@ public async Task SemanticSearch_ReturnsResults() if (response!.Results.Count == 0) await LogIndexCount(clientAccessor, TestContext.Current.CancellationToken); - response.Results.Should().NotBeEmpty($"Search for 'elasticsearch getting started' should return results (index: {clientAccessor.SearchIndex})"); + response.Results + .Should() + .NotBeEmpty($"Search for 'elasticsearch getting started' should return results (index: {clientAccessor.SearchIndex})"); response.TotalHits.Should().BeGreaterThan(0); Output.WriteLine($"Total hits: {response.TotalHits}"); Output.WriteLine($"Results returned: {response.Results.Count}"); @@ -53,10 +54,12 @@ public async Task SemanticSearch_WithProductFilter() Assert.SkipUnless(canConnect, "Elasticsearch is not connected"); // Act - var resultJson = await searchTools.SemanticSearch( - "getting started", - productFilter: "elasticsearch", - cancellationToken: TestContext.Current.CancellationToken); + var resultJson = + await searchTools.SemanticSearch( + "getting started", + productFilter: "elasticsearch", + cancellationToken: TestContext.Current.CancellationToken + ); // Assert Output.WriteLine($"Result: {resultJson}"); @@ -81,10 +84,8 @@ public async Task FindRelatedDocs_ReturnsRelated() Assert.SkipUnless(canConnect, "Elasticsearch is not connected"); // Act - var resultJson = await searchTools.FindRelatedDocs( - "data streams", - limit: 5, - cancellationToken: TestContext.Current.CancellationToken); + var resultJson = + await searchTools.FindRelatedDocs("data streams", limit: 5, cancellationToken: TestContext.Current.CancellationToken); // Assert Output.WriteLine($"Result: {resultJson}"); @@ -94,7 +95,9 @@ public async Task FindRelatedDocs_ReturnsRelated() if (response!.RelatedDocs.Count == 0) await LogIndexCount(clientAccessor!, TestContext.Current.CancellationToken); - response.RelatedDocs.Should().NotBeEmpty($"Finding related docs for 'data streams' should return results (index: {clientAccessor!.SearchIndex})"); + response.RelatedDocs + .Should() + .NotBeEmpty($"Finding related docs for 'data streams' should return results (index: {clientAccessor!.SearchIndex})"); response.Count.Should().BeGreaterThan(0); Output.WriteLine($"Related docs count: {response.Count}"); } diff --git a/tests-integration/Search.IntegrationTests/SearchRelevanceTests.cs b/tests-integration/Search.IntegrationTests/SearchRelevanceTests.cs index d448f4559e..509b3877fd 100644 --- a/tests-integration/Search.IntegrationTests/SearchRelevanceTests.cs +++ b/tests-integration/Search.IntegrationTests/SearchRelevanceTests.cs @@ -28,56 +28,88 @@ public class SearchRelevanceTests(ITestOutputHelper output) /// Theory data for search queries mapped to expected first hit URLs. /// Same as SearchIntegrationTests but with detailed explain output on failures. /// - public static TheoryData SearchQueryTestCases => new() - { - //TODO these results reflect today's result, we still have some work to do to improve the relevance of the search results - - // Elasticsearch specific queries - { "elasticsearch get started", "/docs/solutions/search/get-started", null }, - { "elasticsearch getting started", "/docs/solutions/search/get-started", null }, - { "elastic common schema", "/docs/reference/ecs", null }, - { "ecs", "/docs/reference/ecs", null }, - { "c# client", "/docs/reference/elasticsearch/clients/dotnet/installation", ["/docs/reference/elasticsearch/clients/dotnet"] }, - { "dotnet client", "/docs/reference/elasticsearch/clients/dotnet/installation", ["/docs/reference/elasticsearch/clients/dotnet"] }, - { "runscript", "/docs/api/doc/kibana/operation/operation-runscriptaction", [ "/docs/solutions/security/endpoint-response-actions" ] }, - { "data-streams", "/docs/manage-data/data-store/data-streams", null }, - { "datastream", "/docs/manage-data/data-store/data-streams", null }, - { "data stream", "/docs/manage-data/data-store/data-streams", null }, - { "saml sso", "/docs/deploy-manage/users-roles/cloud-organization/configure-saml-authentication", ["/docs/deploy-manage/users-roles/cloud-organization/configure-saml-authentication"] }, - { "templates", "/docs/manage-data/data-store/templates", null}, - // different results because of the exact match on title, QueryDSL needs to be normalized in the content - { "query dsl", "/docs/explore-analyze/query-filter/languages/querydsl", ["/docs/explore-analyze/query-filter/languages/querydsl"]}, - { "querydsl", "/docs/reference/query-languages/querydsl", ["/docs/explore-analyze/query-filter/languages/querydsl"]}, - { "Agent policy", "/docs/reference/fleet/agent-policy", null}, - { "aliases", "/docs/manage-data/data-store/aliases", null}, - { "Kibana privilege", "/docs/deploy-manage/users-roles/cluster-or-deployment-auth/kibana-privileges", null}, - { "lens", "/docs/explore-analyze/visualize/lens", null}, - { "machine learning node", "/docs/deploy-manage/autoscaling/autoscaling-in-ece-and-ech", null }, - { "machine learning", "/docs/reference/machine-learning", null}, - { "ml", "/docs/reference/machine-learning", null}, - { "elasticsearch", "/docs/reference/elasticsearch", null}, - { "kibana", "/docs/reference/kibana", null}, - { "cloud", "/docs/reference/cloud", null}, - { "logstash", "/docs/reference/logstash", null}, - { "logstash release", "/docs/release-notes/logstash", null}, - { "esql", "/docs/reference/query-languages/esql", null}, - { "ES|QL", "/docs/reference/query-languages/esql", null}, - { "Output plugins for Logstash", "/docs/reference/logstash/plugins/output-plugins", null}, - // exact match on title wins but with variations we prefer the more general topic page - { "Sending data to Elastic Cloud Hosted", "/docs/reference/logstash/connecting-to-cloud", ["/docs/solutions/observability/get-started/quickstart-elastic-cloud-otel-endpoint"]}, - { "Send data to Elastic Cloud Hosted", "/docs/solutions/observability/get-started/quickstart-elastic-cloud-otel-endpoint", ["/docs/reference/logstash/connecting-to-cloud"]}, - - { "universal profiling", "/docs/solutions/observability/infra-and-hosts/universal-profiling", null}, - { "agg", "/docs/explore-analyze/query-filter/aggregations", null}, - { "a", "/docs/reference/apm/observability/apm", null}, - { "index.number_of_replicas", "/docs/reference/elasticsearch/index-settings/index-modules", null}, - //{ "index.use_time_series_doc_values_format", "/docs/reference/elasticsearch/index-settings/index-modules", null}, - //universal profiling - }; + public static TheoryData SearchQueryTestCases => + new() + { + //TODO these results reflect today's result, we still have some work to do to improve the relevance of the search results + + // Elasticsearch specific queries + { + "elasticsearch get started", + "/docs/solutions/search/get-started", + null + }, + { "elasticsearch getting started", "/docs/solutions/search/get-started", null }, + { "elastic common schema", "/docs/reference/ecs", null }, + { "ecs", "/docs/reference/ecs", null }, + { "c# client", "/docs/reference/elasticsearch/clients/dotnet/installation", ["/docs/reference/elasticsearch/clients/dotnet"] }, + { + "dotnet client", + "/docs/reference/elasticsearch/clients/dotnet/installation", + ["/docs/reference/elasticsearch/clients/dotnet"] + }, + { + "runscript", + "/docs/api/doc/kibana/operation/operation-runscriptaction", + ["/docs/solutions/security/endpoint-response-actions"] + }, + { "data-streams", "/docs/manage-data/data-store/data-streams", null }, + { "datastream", "/docs/manage-data/data-store/data-streams", null }, + { "data stream", "/docs/manage-data/data-store/data-streams", null }, + { + "saml sso", + "/docs/deploy-manage/users-roles/cloud-organization/configure-saml-authentication", + ["/docs/deploy-manage/users-roles/cloud-organization/configure-saml-authentication"] + }, + { "templates", "/docs/manage-data/data-store/templates", null }, + // different results because of the exact match on title, QueryDSL needs to be normalized in the content + { + "query dsl", + "/docs/explore-analyze/query-filter/languages/querydsl", + ["/docs/explore-analyze/query-filter/languages/querydsl"] + }, + { "querydsl", "/docs/reference/query-languages/querydsl", ["/docs/explore-analyze/query-filter/languages/querydsl"] }, + { "Agent policy", "/docs/reference/fleet/agent-policy", null }, + { "aliases", "/docs/manage-data/data-store/aliases", null }, + { "Kibana privilege", "/docs/deploy-manage/users-roles/cluster-or-deployment-auth/kibana-privileges", null }, + { "lens", "/docs/explore-analyze/visualize/lens", null }, + { "machine learning node", "/docs/deploy-manage/autoscaling/autoscaling-in-ece-and-ech", null }, + { "machine learning", "/docs/reference/machine-learning", null }, + { "ml", "/docs/reference/machine-learning", null }, + { "elasticsearch", "/docs/reference/elasticsearch", null }, + { "kibana", "/docs/reference/kibana", null }, + { "cloud", "/docs/reference/cloud", null }, + { "logstash", "/docs/reference/logstash", null }, + { "logstash release", "/docs/release-notes/logstash", null }, + { "esql", "/docs/reference/query-languages/esql", null }, + { "ES|QL", "/docs/reference/query-languages/esql", null }, + { "Output plugins for Logstash", "/docs/reference/logstash/plugins/output-plugins", null }, + // exact match on title wins but with variations we prefer the more general topic page + { + "Sending data to Elastic Cloud Hosted", + "/docs/reference/logstash/connecting-to-cloud", + ["/docs/solutions/observability/get-started/quickstart-elastic-cloud-otel-endpoint"] + }, + { + "Send data to Elastic Cloud Hosted", + "/docs/solutions/observability/get-started/quickstart-elastic-cloud-otel-endpoint", + ["/docs/reference/logstash/connecting-to-cloud"] + }, + { "universal profiling", "/docs/solutions/observability/infra-and-hosts/universal-profiling", null }, + { "agg", "/docs/explore-analyze/query-filter/aggregations", null }, + { "a", "/docs/reference/apm/observability/apm", null }, + { "index.number_of_replicas", "/docs/reference/elasticsearch/index-settings/index-modules", null }, + //{ "index.use_time_series_doc_values_format", "/docs/reference/elasticsearch/index-settings/index-modules", null}, + //universal profiling + }; [Theory] [MemberData(nameof(SearchQueryTestCases))] - public async Task SearchReturnsExpectedFirstResultWithExplain(string query, string expectedFirstResultUrl, string[]? additionalExpectedUrls) + public async Task SearchReturnsExpectedFirstResultWithExplain( + string query, + string expectedFirstResultUrl, + string[]? additionalExpectedUrls + ) { // Arrange - Create ElasticsearchGateway directly var (gateway, clientAccessor) = CreateFindPageGateway(); @@ -91,9 +123,11 @@ public async Task SearchReturnsExpectedFirstResultWithExplain(string query, stri Assert.SkipUnless(canConnect, "Elasticsearch is not connected"); // Act - Perform the search via the adapter's autocomplete path - var searchResult = await gateway.NavigationSearchAsync( - new NavigationSearchRequest { Query = query, PageNumber = 1, PageSize = 5 }, - TestContext.Current.CancellationToken); + var searchResult = + await gateway.NavigationSearchAsync( + new NavigationSearchRequest { Query = query, PageNumber = 1, PageSize = 5 }, + TestContext.Current.CancellationToken + ); // Log basic results output.WriteLine($"Query: {query}"); @@ -104,8 +138,11 @@ public async Task SearchReturnsExpectedFirstResultWithExplain(string query, stri if (results.Count == 0) { - var countResponse = await clientAccessor.Client.CountAsync(c => c.Indices(clientAccessor.SearchIndex), TestContext.Current.CancellationToken); - output.WriteLine($"Index document count: {(countResponse.IsValidResponse ? countResponse.Count.ToString(CultureInfo.InvariantCulture) : $"ERROR: {countResponse.ElasticsearchServerError?.Error?.Reason}")}"); + var countResponse = + await clientAccessor.Client.CountAsync(c => c.Indices(clientAccessor.SearchIndex), TestContext.Current.CancellationToken); + output.WriteLine( + $"Index document count: {(countResponse.IsValidResponse ? countResponse.Count.ToString(CultureInfo.InvariantCulture) : $"ERROR: {countResponse.ElasticsearchServerError?.Error?.Reason}")}" + ); } results.Should().NotBeEmpty($"Search for '{query}' should return results (index: {clientAccessor.SearchIndex})"); @@ -118,10 +155,8 @@ public async Task SearchReturnsExpectedFirstResultWithExplain(string query, stri output.WriteLine("\n❌ FIRST RESULT MISMATCH - Fetching detailed explanations...\n"); // Get explain for both the actual top result and the expected result - var (topResultExplain, expectedResultExplain) = await gateway.ExplainTopResultAndExpectedAsync( - query, - expectedFirstResultUrl, - TestContext.Current.CancellationToken); + var (topResultExplain, expectedResultExplain) = + await gateway.ExplainTopResultAndExpectedAsync(query, expectedFirstResultUrl, TestContext.Current.CancellationToken); // Output the actual top result explanation output.WriteLine("═══════════════════════════════════════════════════════════════"); @@ -146,7 +181,8 @@ public async Task SearchReturnsExpectedFirstResultWithExplain(string query, stri // Create a detailed failure message var scoreDiff = topResultExplain.Score - expectedResultExplain.Score; - var failureMessage = $@" + var failureMessage = + $@" First result for query '{query}' did not match expectation. Expected: {expectedFirstResultUrl} @@ -191,7 +227,10 @@ public async Task SearchReturnsExpectedFirstResultWithExplain(string query, stri { output.WriteLine($" {i + 1}. {results[i].Url} (score: {results[i].Score:F4})"); } - resultUrls.Should().Contain(expectedUrl, $"Expected URL '{expectedUrl}' should be present on the first page of results for query '{query}'"); + resultUrls.Should().Contain( + expectedUrl, + $"Expected URL '{expectedUrl}' should be present on the first page of results for query '{query}'" + ); } } } @@ -214,10 +253,8 @@ public async Task ExplainTopResultAndExpectedAsyncReturnsDetailedScoring() const string expectedUrl = "/docs/reference/elasticsearch/clients/java/getting-started"; // Act - Use the ExplainTopResultAndExpectedAsync method which gets top result and explains both - var (topResultExplain, expectedResultExplain) = await gateway.ExplainTopResultAndExpectedAsync( - query, - expectedUrl, - TestContext.Current.CancellationToken); + var (topResultExplain, expectedResultExplain) = + await gateway.ExplainTopResultAndExpectedAsync(query, expectedUrl, TestContext.Current.CancellationToken); // Assert - Top result should have explanation output.WriteLine($"Query: {query}"); @@ -246,7 +283,11 @@ public async Task ExplainTopResultAndExpectedAsyncReturnsDetailedScoring() private static (NavigationSearchService Gateway, ElasticsearchClientAccessor ClientAccessor) CreateFindPageGateway() { var endpoints = ElasticsearchEndpointFactory.Create(buildType: "assembler"); - var configProvider = new ConfigurationFileProvider(NullLoggerFactory.Instance, new ConfigurationFileSystem(), configurationSource: ConfigurationSource.Embedded); + var configProvider = new ConfigurationFileProvider( + NullLoggerFactory.Instance, + new ConfigurationFileSystem(), + configurationSource: ConfigurationSource.Embedded + ); var searchConfig = configProvider.CreateSearchConfiguration(); var clientAccessor = new ElasticsearchClientAccessor(endpoints, searchConfig); @@ -259,8 +300,11 @@ private static (NavigationSearchService Gateway, ElasticsearchClientAccessor Cli SemanticEnabled = true }; var inner = new DefaultSearchService( - clientAccessor.Client, clientAccessor.SearchIndex, queryConfig, - NullLogger>.Instance); + clientAccessor.Client, + clientAccessor.SearchIndex, + queryConfig, + NullLogger>.Instance + ); var gateway = new NavigationSearchService(inner, clientAccessor, NullLogger.Instance); return (gateway, clientAccessor); diff --git a/tests/Elastic.ApiExplorer.Tests/ApiExplorerFixture.cs b/tests/Elastic.ApiExplorer.Tests/ApiExplorerFixture.cs index 3bbca387f2..6711f53c62 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiExplorerFixture.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiExplorerFixture.cs @@ -47,8 +47,9 @@ public async ValueTask InitializeAsync() var fs = new FileSystem(); var path = fs.Path.Combine(AppContext.BaseDirectory, "TestData", "api-explorer-fixture.json"); - Document = await OpenApiReader.Instance.ReadAsync(fs.FileInfo.New(path)) - ?? throw new InvalidOperationException($"Could not read fixture spec at {path}"); + Document = + await OpenApiReader.Instance.ReadAsync(fs.FileInfo.New(path)) ?? + throw new InvalidOperationException($"Could not read fixture spec at {path}"); var generator = new OpenApiGenerator(NullLoggerFactory.Instance, Context, PassthroughMarkdownRenderer.Instance); Navigation = generator.CreateNavigation("fixture", Document); diff --git a/tests/Elastic.ApiExplorer.Tests/ApiMarkdownIntraApiLinkTests.cs b/tests/Elastic.ApiExplorer.Tests/ApiMarkdownIntraApiLinkTests.cs index a85fb3ade7..9e4779d542 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiMarkdownIntraApiLinkTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiMarkdownIntraApiLinkTests.cs @@ -37,8 +37,16 @@ public void Render_RewritesGroupAndOperationLinksAgainstCurrentApiBase() var renderer = new CapturingRenderer(); var collector = new DiagnosticsCollector([]); var fs = new FileSystem(); - var context = new BuildContext(collector, DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName), TestHelpers.CreateConfigurationContext(fs)); - var renderContext = new ApiRenderContext(context, new OpenApiDocument(), new StaticFileContentHashProvider(new EmbeddedOrPhysicalFileProvider(context))) + var context = new BuildContext( + collector, + DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName), + TestHelpers.CreateConfigurationContext(fs) + ); + var renderContext = new ApiRenderContext( + context, + new OpenApiDocument(), + new StaticFileContentHashProvider(new EmbeddedOrPhysicalFileProvider(context)) + ) { NavigationHtml = string.Empty, CurrentNavigation = new LandingNavigationItem("/api/doc/kibana").Index, @@ -46,7 +54,8 @@ public void Render_RewritesGroupAndOperationLinksAgainstCurrentApiBase() ApiExplorerLog = null }; - var markdown = """ + var markdown = + """ See [data views](../group/endpoint-data-views) and [export](../operation/operation-post-saved-objects-export). """; diff --git a/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs b/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs index e10b977137..1f9cd5e425 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs @@ -5,9 +5,9 @@ using System.IO.Abstractions; using System.Text.RegularExpressions; using AwesomeAssertions; -using Elastic.ApiExplorer._Partials.Layout; using Elastic.ApiExplorer.Infrastructure; using Elastic.ApiExplorer.Landing; +using Elastic.ApiExplorer._Partials.Layout; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; @@ -29,7 +29,8 @@ public async Task Render_MarksOnlyCurrentVersionSelected() var context = new BuildContext( new DiagnosticsCollector([]), DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName), - TestHelpers.CreateConfigurationContext(fs)); + TestHelpers.CreateConfigurationContext(fs) + ); var navigationItem = new LandingNavigationItem("/api/doc/elasticsearch/v9/").Index; var model = new ApiLayoutViewModel { diff --git a/tests/Elastic.ApiExplorer.Tests/ApiPropertyTreeBuilderTests.cs b/tests/Elastic.ApiExplorer.Tests/ApiPropertyTreeBuilderTests.cs index f7eebd5a1a..f1e31d4eaf 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiPropertyTreeBuilderTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiPropertyTreeBuilderTests.cs @@ -32,7 +32,10 @@ public void BuildPropertyList_RecursiveSchema_StopsAtAncestor() var builder = CreateBuilder(currentPageType: "QueryContainer"); var ancestors = new HashSet { "QueryContainer" }; - var list = builder.BuildPropertyList(Schema("_types.query_dsl.QueryContainer"), new PropertyTreeScope { Prefix = "", Ancestors = ancestors }); + var list = builder.BuildPropertyList( + Schema("_types.query_dsl.QueryContainer"), + new PropertyTreeScope { Prefix = "", Ancestors = ancestors } + ); list.Should().NotBeNull(); var boolProp = list.Items.Single(p => p.Name == "bool"); @@ -49,7 +52,10 @@ public void BuildPropertyList_SimpleArrayUnion_DetectsFieldOrFieldArray() { var builder = CreateBuilder(); - var list = builder.BuildPropertyList(Schema("fixture.SearchRequestBody"), new PropertyTreeScope { Prefix = "req", IsRequest = true }); + var list = builder.BuildPropertyList( + Schema("fixture.SearchRequestBody"), + new PropertyTreeScope { Prefix = "req", IsRequest = true } + ); var fields = list!.Items.Single(p => p.Name == "fields"); fields.Union.Should().NotBeNull(); @@ -64,7 +70,10 @@ public void BuildPropertyList_DictionaryOfLinkedType_LinksInsteadOfExpanding() { var builder = CreateBuilder(); - var list = builder.BuildPropertyList(Schema("fixture.SearchRequestBody"), new PropertyTreeScope { Prefix = "req", IsRequest = true }); + var list = builder.BuildPropertyList( + Schema("fixture.SearchRequestBody"), + new PropertyTreeScope { Prefix = "req", IsRequest = true } + ); var aggs = list!.Items.Single(p => p.Name == "aggs"); aggs.Children.Kind.Should().Be(ChildKind.None, "the dictionary value type has its own page"); @@ -78,7 +87,10 @@ public void BuildPropertyList_RequiredProperty_IsMarkedRequired() { var builder = CreateBuilder(); - var list = builder.BuildPropertyList(Schema("fixture.SearchRequestBody"), new PropertyTreeScope { Prefix = "req", IsRequest = true }); + var list = builder.BuildPropertyList( + Schema("fixture.SearchRequestBody"), + new PropertyTreeScope { Prefix = "req", IsRequest = true } + ); list!.Items.Single(p => p.Name == "query").IsRequired.Should().BeTrue(); list.Items.Single(p => p.Name == "sort").IsRequired.Should().BeFalse(); diff --git a/tests/Elastic.ApiExplorer.Tests/ApiUrlBuilderTests.cs b/tests/Elastic.ApiExplorer.Tests/ApiUrlBuilderTests.cs index fb9a155248..d4da5dac50 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiUrlBuilderTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiUrlBuilderTests.cs @@ -14,16 +14,12 @@ public class ApiUrlBuilderTests [InlineData("elasticsearch", "9", "elasticsearch/v9")] [InlineData("elasticsearch", "8", "elasticsearch/v8")] [InlineData("kibana", "10", "kibana/v10")] - public void ProductSuffix_MapsVersionMonikersToPathSuffixes(string apiKey, string versionMoniker, string expected) - { + public void ProductSuffix_MapsVersionMonikersToPathSuffixes(string apiKey, string versionMoniker, string expected) => ApiUrlBuilder.ProductSuffix(apiKey, versionMoniker).Should().Be(expected); - } [Theory] [InlineData("", "elasticsearch", "/api/doc/elasticsearch")] [InlineData("", "elasticsearch/v9", "/api/doc/elasticsearch/v9")] - public void ProductRoot_UsesVersionAwareSuffix(string urlPathPrefix, string apiUrlSuffix, string expected) - { + public void ProductRoot_UsesVersionAwareSuffix(string urlPathPrefix, string apiUrlSuffix, string expected) => ApiUrlBuilder.ProductRoot(urlPathPrefix, apiUrlSuffix).Should().Be(expected); - } } diff --git a/tests/Elastic.ApiExplorer.Tests/ApiVersionSwitcherTests.cs b/tests/Elastic.ApiExplorer.Tests/ApiVersionSwitcherTests.cs index 861ff54d4a..1d5e361a0e 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiVersionSwitcherTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiVersionSwitcherTests.cs @@ -20,18 +20,11 @@ public void Build_SingleVersion_ReturnsEmpty() [Fact] public void Build_MultipleVersions_OrdersLatestFirstAndMarksCurrent() { - var items = ApiVersionSwitcher.Build( - "", - "elasticsearch", - ["main", "9", "8"], - "8"); + var items = ApiVersionSwitcher.Build("", "elasticsearch", ["main", "9", "8"], "8"); items.Should().HaveCount(3); items.Select(i => i.Label).Should().Equal("Latest", "9.x", "8.x"); - items.Select(i => i.Url).Should().Equal( - "/api/doc/elasticsearch/", - "/api/doc/elasticsearch/v9/", - "/api/doc/elasticsearch/v8/"); + items.Select(i => i.Url).Should().Equal("/api/doc/elasticsearch/", "/api/doc/elasticsearch/v9/", "/api/doc/elasticsearch/v8/"); items.Single(i => i.Selected).Label.Should().Be("8.x"); } } diff --git a/tests/Elastic.ApiExplorer.Tests/AvailabilityBadgeHelperTests.cs b/tests/Elastic.ApiExplorer.Tests/AvailabilityBadgeHelperTests.cs index e6bf02422d..87637a5928 100644 --- a/tests/Elastic.ApiExplorer.Tests/AvailabilityBadgeHelperTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/AvailabilityBadgeHelperTests.cs @@ -22,10 +22,8 @@ public class AvailabilityBadgeHelperTests [InlineData("Technical Preview; added in 9.4.0", "preview 9.4.0")] [InlineData("Generally available; added in 9.1.0", "ga 9.1.0")] [InlineData("Added in 7.7.0", "ga 7.7.0")] - public void ProjectToLifecycleFormat_MapsXStateToLifecycleString(string xState, string expected) - { + public void ProjectToLifecycleFormat_MapsXStateToLifecycleString(string xState, string expected) => AvailabilityBadgeHelper.ProjectToLifecycleFormat(xState).Should().Be(expected); - } [Fact] public void FromOperation_ExperimentalXState_ProducesExperimentalBadge() diff --git a/tests/Elastic.ApiExplorer.Tests/CodeSampleTests.cs b/tests/Elastic.ApiExplorer.Tests/CodeSampleTests.cs index ee8cf39a71..84a13e0212 100644 --- a/tests/Elastic.ApiExplorer.Tests/CodeSampleTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/CodeSampleTests.cs @@ -134,17 +134,13 @@ public void CodeSamples_HandlesEmptyArray() [InlineData("Java", "language-java")] [InlineData("Go", "language-go")] [InlineData("TypeScript", "language-typescript")] - public void GetHighlightClass_MapsLanguagesCorrectly(string language, string expected) - { + public void GetHighlightClass_MapsLanguagesCorrectly(string language, string expected) => CodeSample.GetHighlightClass(language).Should().Be(expected); - } [Fact] public void CodeSamples_SetsCorrectHighlightClass() { - var samples = new JsonArray( - new JsonObject { ["lang"] = "curl", ["source"] = "curl -X GET ..." } - ); + var samples = new JsonArray(new JsonObject { ["lang"] = "curl", ["source"] = "curl -X GET ..." }); var operation = CreateOperationWithCodeSamples(samples); var result = OpenApiExtensionReader.ParseCodeSamples(operation); @@ -165,8 +161,7 @@ public void GetHighlightGroupClass_HandlesNonLanguageClass() => CodeSample.GetHighlightGroupClass("some-other-class").Should().Be("highlight-plaintext"); [Fact] - public void GetHighlightGroupClass_HandlesEmptyInput() => - CodeSample.GetHighlightGroupClass("").Should().Be("highlight-plaintext"); + public void GetHighlightGroupClass_HandlesEmptyInput() => CodeSample.GetHighlightGroupClass("").Should().Be("highlight-plaintext"); [Fact] public void GetHighlightGroupClass_HandlesLanguagePrefixOnly() => diff --git a/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs b/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs index 2a11d229a1..31209bf920 100644 --- a/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs @@ -25,7 +25,11 @@ public class DashboardOpenApiNavigationTests public async Task CreateNavigation_SingleTagOpenApiSpec_HasSidebarItems() { var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem()); - var context = new BuildContext(new DiagnosticsCollector([]), DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName), configurationContext); + var context = new BuildContext( + new DiagnosticsCollector([]), + DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName), + configurationContext + ); var fs = new FileSystem(); var path = fs.Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "dashboard-openapi.json"); var fi = fs.FileInfo.New(path); diff --git a/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs b/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs index 6e5fb78691..f858315d43 100644 --- a/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs @@ -30,8 +30,7 @@ public class KibanaApiMarkdownNavigationTests private sealed class StubMarkdownRenderer : IMarkdownStringRenderer { public string Render(string markdown, IFileInfo? source) => "

stub-body

"; - public string RenderPreservingFirstHeading(string markdown, IFileInfo? source) => - "

Kibana spaces

stub-body

"; + public string RenderPreservingFirstHeading(string markdown, IFileInfo? source) => "

Kibana spaces

stub-body

"; } private static (LandingNavigationItem navigation, SimpleMarkdownNavigationItem introNav) SetupKibanaNavigation() @@ -54,7 +53,11 @@ private static (LandingNavigationItem navigation, SimpleMarkdownNavigationItem i var collector = new DiagnosticsCollector([]); var configurationContext = TestHelpers.CreateConfigurationContext(fs); - var context = new BuildContext(collector, DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName), configurationContext); + var context = new BuildContext( + collector, + DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName), + configurationContext + ); var doc = OpenApiReader.Instance.ReadAsync(specFile).GetAwaiter().GetResult(); doc.Should().NotBeNull("OpenAPI document should load successfully"); var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance); diff --git a/tests/Elastic.ApiExplorer.Tests/OpenApiDocumentExporterTests.cs b/tests/Elastic.ApiExplorer.Tests/OpenApiDocumentExporterTests.cs index 21e07ad659..8dfb518361 100644 --- a/tests/Elastic.ApiExplorer.Tests/OpenApiDocumentExporterTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OpenApiDocumentExporterTests.cs @@ -70,7 +70,8 @@ public async Task ExportedDocumentUrlsShouldReturnSuccessStatusCode() // Test each URL in parallel var failures = new ConcurrentBag<(string Url, int StatusCode)>(); - await Parallel.ForEachAsync(sample, + await Parallel.ForEachAsync( + sample, new ParallelOptions { MaxDegreeOfParallelism = 10, CancellationToken = TestContext.Current.CancellationToken }, async (url, ct) => { @@ -81,8 +82,14 @@ await Parallel.ForEachAsync(sample, using var request = new HttpRequestMessage(HttpMethod.Head, fullUrl); // Mimic browser headers - request.Headers.Add("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); - request.Headers.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"); + request.Headers.Add( + "User-Agent", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + ); + request.Headers.Add( + "Accept", + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8" + ); request.Headers.Add("Accept-Language", "en-US,en;q=0.9"); request.Headers.Add("Accept-Encoding", "gzip, deflate, br"); request.Headers.Add("DNT", "1"); @@ -94,11 +101,7 @@ await Parallel.ForEachAsync(sample, request.Headers.Add("Sec-Fetch-User", "?1"); request.Headers.Add("Cache-Control", "max-age=0"); - var response = await HttpClient.SendAsync( - request, - HttpCompletionOption.ResponseHeadersRead, - ct - ); + var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); if (!response.IsSuccessStatusCode) { @@ -109,7 +112,8 @@ await Parallel.ForEachAsync(sample, { failures.Add((url, -1)); // Use -1 to indicate exception } - }); + } + ); // Assert all URLs returned 200 failures.Should().BeEmpty( diff --git a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCatalogSplitTests.cs b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCatalogSplitTests.cs index 4fe923a343..d68ae7b572 100644 --- a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCatalogSplitTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCatalogSplitTests.cs @@ -36,7 +36,12 @@ public async Task GenerateProducts_DoesNotWriteCatalogPage() using var versionIndexClient = new VersionIndexClient(BaseUri, MultiVersionHandler(), sleep: (_, _) => Task.CompletedTask); var reader = CreateSequentialReader(SpecDocument("Elasticsearch main")); var generator = new OpenApiGenerator( - NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance, versionIndexClient, reader); + NullLoggerFactory.Instance, + context, + NoopMarkdownStringRenderer.Instance, + versionIndexClient, + reader + ); var entries = await generator.GenerateProducts(TestContext.Current.CancellationToken); @@ -51,8 +56,7 @@ public async Task GenerateCatalog_WritesCombinedCatalogFromMultipleEntries() var outputRoot = Path.Join(Paths.WorkingDirectoryRoot.FullName, $"api-catalog-split-{Guid.NewGuid():N}"); var context = CreateGenerateContext(outputRoot); using var versionIndexClient = new VersionIndexClient(BaseUri, MultiVersionHandler(), sleep: (_, _) => Task.CompletedTask); - var generator = new OpenApiGenerator( - NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance, versionIndexClient); + var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance, versionIndexClient); var entries = new List { new("elasticsearch", "Elasticsearch", "/docs/api/doc/elasticsearch/"), @@ -72,7 +76,12 @@ public async Task Generate_StillWritesProductsAndCatalog() using var versionIndexClient = new VersionIndexClient(BaseUri, MultiVersionHandler(), sleep: (_, _) => Task.CompletedTask); var reader = CreateSequentialReader(SpecDocument("Elasticsearch main")); var generator = new OpenApiGenerator( - NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance, versionIndexClient, reader); + NullLoggerFactory.Instance, + context, + NoopMarkdownStringRenderer.Instance, + versionIndexClient, + reader + ); await generator.Generate(TestContext.Current.CancellationToken); @@ -87,7 +96,8 @@ private static BuildContext CreateGenerateContext(string outputRoot) var product = TestHelpers.CreateProduct("elasticsearch", stack.GetVersioningSystem(VersioningSystemId.Stack)); var repoRoot = Path.Join(Paths.WorkingDirectoryRoot.FullName, $"api-catalog-split-repo-{Guid.NewGuid():N}"); var configPath = Path.Join(repoRoot, "docs", "docset.yml"); - var docsetYaml = """ + var docsetYaml = + """ api: elasticsearch: - spec: elasticsearch-openapi.json @@ -100,57 +110,62 @@ private static BuildContext CreateGenerateContext(string outputRoot) { Products = new[] { product }.ToFrozenDictionary(p => p.Id, StringComparer.OrdinalIgnoreCase), PublicReferenceProducts = new[] { product }.ToFrozenDictionary(p => p.Id, StringComparer.OrdinalIgnoreCase), - ProductDisplayNames = new Dictionary { [product.Id] = product.DisplayName ?? product.Id } - .ToFrozenDictionary(StringComparer.OrdinalIgnoreCase) + ProductDisplayNames = + new Dictionary { [product.Id] = product.DisplayName ?? product.Id }.ToFrozenDictionary( + StringComparer.OrdinalIgnoreCase + ) }; var configurationContext = TestHelpers.CreateConfigurationContext(fs, stack, products); - return new BuildContext(collector, - DocumentationFileSystem.Resolve(repoRoot, new DocumentationScopeOptions - { - ConfigurationFile = configPath, - Output = outputRoot, - Git = new GitCheckoutInformation + return new BuildContext( + collector, + DocumentationFileSystem.Resolve( + repoRoot, + new DocumentationScopeOptions { - Branch = "main", - Remote = "https://github.com/elastic/elasticsearch.git", - Ref = "refs/heads/main" - }, - Inner = fs - }), - configurationContext) - { - UrlPathPrefix = "docs" - }; + ConfigurationFile = configPath, + Output = outputRoot, + Git = new GitCheckoutInformation + { + Branch = "main", + Remote = "https://github.com/elastic/elasticsearch.git", + Ref = "refs/heads/main" + }, + Inner = fs + } + ), + configurationContext + ) + { UrlPathPrefix = "docs" }; } - private static OpenApiDocument SpecDocument(string title) => new() - { - Info = new OpenApiInfo { Title = title, Version = "1.0" }, - Paths = new OpenApiPaths + private static OpenApiDocument SpecDocument(string title) => + new() { - ["/ping"] = new OpenApiPathItem + Info = new OpenApiInfo { Title = title, Version = "1.0" }, + Paths = new OpenApiPaths { - Operations = new Dictionary + ["/ping"] = new OpenApiPathItem { - [HttpMethod.Get] = new() + Operations = new Dictionary { - OperationId = "ping", - Tags = new HashSet { new("core") }, - Responses = new OpenApiResponses { ["200"] = new OpenApiResponse { Description = "ok" } } + [HttpMethod.Get] = new() + { + OperationId = "ping", + Tags = new HashSet { new("core") }, + Responses = new OpenApiResponses { ["200"] = new OpenApiResponse { Description = "ok" } } + } } } - } - }, - Tags = new HashSet { new() { Name = "core" } } - }; + }, + Tags = new HashSet { new() { Name = "core" } } + }; private static IOpenApiSpecificationReader CreateSequentialReader(params OpenApiDocument[] documents) { var queue = new Queue(documents); var reader = A.Fake(); - A.CallTo(() => reader.ReadAsync(A._, A._)) - .ReturnsLazily(_ => Task.FromResult(queue.Dequeue())); + A.CallTo(() => reader.ReadAsync(A._, A._)).ReturnsLazily(_ => Task.FromResult(queue.Dequeue())); return reader; } @@ -159,7 +174,8 @@ private static HttpMessageHandler MultiVersionHandler(string repository = "elast { if (request.RequestUri!.AbsolutePath.EndsWith("index.json", StringComparison.Ordinal)) { - return IndexResponse(/*lang=json,strict*/ $$""" + return IndexResponse(/*lang=json,strict*/ + $$""" { "{{repository}}": { "elasticsearch-openapi.json": { @@ -167,7 +183,8 @@ private static HttpMessageHandler MultiVersionHandler(string repository = "elast } } } - """); + """ + ); } return SpecResponse(); @@ -176,12 +193,17 @@ private static HttpMessageHandler MultiVersionHandler(string repository = "elast private static HttpResponseMessage IndexResponse(string body) => new(HttpStatusCode.OK) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") }; - private static HttpResponseMessage SpecResponse() => new(HttpStatusCode.OK) - { - Content = new StringContent( - /*lang=json,strict*/ """{"openapi":"3.1.0","info":{"title":"Spec","version":"1.0"},"paths":{}}""", - System.Text.Encoding.UTF8, "application/json") - }; + private static HttpResponseMessage SpecResponse() => + new(HttpStatusCode.OK) + { + Content = + new StringContent( + /*lang=json,strict*/ + """{"openapi":"3.1.0","info":{"title":"Spec","version":"1.0"},"paths":{}}""", + System.Text.Encoding.UTF8, + "application/json" + ) + }; private sealed class StubHandler(Func responder) : HttpMessageHandler { diff --git a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs index 6bad9da9f8..14385865c7 100644 --- a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs @@ -28,20 +28,19 @@ private static BuildContext CreateContext( DiagnosticsCollector collector, VersionsConfiguration? versionsConfiguration = null, ProductsConfiguration? productsConfiguration = null, - GitCheckoutInformation? git = null) - { - return new BuildContext(collector, - DocumentationFileSystem.Resolve(new FileSystem().DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), new DocumentationScopeOptions { Git = git }), - TestHelpers.CreateConfigurationContext(new FileSystem(), versionsConfiguration, productsConfiguration)); - } - - private static ResolvedApiConfiguration ApiConfig(Product product, IFileInfo? localSpecFile = null) => new() - { - ProductKey = product.Id, - Product = product, - SpecFileName = "elasticsearch-openapi.json", - LocalSpecFile = localSpecFile - }; + GitCheckoutInformation? git = null + ) => + new BuildContext( + collector, + DocumentationFileSystem.Resolve( + new FileSystem().DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), + new DocumentationScopeOptions { Git = git } + ), + TestHelpers.CreateConfigurationContext(new FileSystem(), versionsConfiguration, productsConfiguration) + ); + + private static ResolvedApiConfiguration ApiConfig(Product product, IFileInfo? localSpecFile = null) => + new() { ProductKey = product.Id, Product = product, SpecFileName = "elasticsearch-openapi.json", LocalSpecFile = localSpecFile }; [Fact] public async Task ResolveDocumentsForProduct_VersionlessLocalSpec_RendersLocalFileWithoutNetwork() @@ -52,12 +51,15 @@ public async Task ResolveDocumentsForProduct_VersionlessLocalSpec_RendersLocalFi var products = new ProductsConfiguration { Products = new Dictionary { [product.Id] = product }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase), - PublicReferenceProducts = new Dictionary { [product.Id] = product }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase), - ProductDisplayNames = new Dictionary { [product.Id] = product.DisplayName }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase) + PublicReferenceProducts = + new Dictionary { [product.Id] = product }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase), + ProductDisplayNames = + new Dictionary { [product.Id] = product.DisplayName }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase) }; var context = CreateContext(collector, versionless, products); var localFile = new FileSystem().FileInfo.New( - Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "elasticsearch-openapi-docs.json")); + Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "elasticsearch-openapi-docs.json") + ); var expectedDocument = SpecDocument(); var reader = A.Fake(); A.CallTo(() => reader.ReadAsync(localFile)).Returns(expectedDocument); @@ -69,10 +71,15 @@ public async Task ResolveDocumentsForProduct_VersionlessLocalSpec_RendersLocalFi context, NoopMarkdownStringRenderer.Instance, versionIndexClient, - reader); + reader + ); - var documents = await generator.ResolveDocumentsForProduct( - "cloud-serverless", ApiConfig(product, localFile), TestContext.Current.CancellationToken); + var documents = + await generator.ResolveDocumentsForProduct( + "cloud-serverless", + ApiConfig(product, localFile), + TestContext.Current.CancellationToken + ); documents.Should().ContainSingle().Which.Document.Should().BeSameAs(expectedDocument); handler.CallCount.Should().Be(0, "a versionless local spec must short-circuit remote version resolution"); @@ -85,11 +92,19 @@ public async Task ResolveDocumentsForProduct_NoLocalSpec_ResolvesRemoteMainThrou var collector = new CapturingDiagnosticsCollector(); var stack = TestHelpers.CreateStackVersionsConfiguration(currentMajor: 9); var product = TestHelpers.CreateProduct("elasticsearch", stack.GetVersioningSystem(VersioningSystemId.Stack)); - var git = new GitCheckoutInformation { Branch = "main", Remote = "https://github.com/elastic/elasticsearch.git", Ref = "refs/heads/main" }; + var git = new GitCheckoutInformation + { + Branch = "main", + Remote = "https://github.com/elastic/elasticsearch.git", + Ref = "refs/heads/main" + }; var context = CreateContext(collector, stack, git: git); - var handler = new StubHandler(request => request.RequestUri!.AbsolutePath.EndsWith("index.json", StringComparison.Ordinal) - ? IndexResponse(/*lang=json,strict*/ """ + var handler = new StubHandler( + request => + request.RequestUri!.AbsolutePath.EndsWith("index.json", StringComparison.Ordinal) + ? IndexResponse(/*lang=json,strict*/ + """ { "elastic/elasticsearch": { "elasticsearch-openapi.json": { @@ -97,8 +112,10 @@ public async Task ResolveDocumentsForProduct_NoLocalSpec_ResolvesRemoteMainThrou } } } - """) - : SpecResponse()); + """ + ) + : SpecResponse() + ); using var versionIndexClient = new VersionIndexClient(BaseUri, handler, sleep: (_, _) => Task.CompletedTask); var expectedDocument = SpecDocument(); var reader = A.Fake(); @@ -108,19 +125,16 @@ public async Task ResolveDocumentsForProduct_NoLocalSpec_ResolvesRemoteMainThrou context, NoopMarkdownStringRenderer.Instance, versionIndexClient, - reader); + reader + ); var errorsBeforeResolution = collector.Errors; - var documents = await generator.ResolveDocumentsForProduct( - "elasticsearch", ApiConfig(product), TestContext.Current.CancellationToken); + var documents = + await generator.ResolveDocumentsForProduct("elasticsearch", ApiConfig(product), TestContext.Current.CancellationToken); documents.Should().ContainSingle().Which.Document.Should().BeSameAs(expectedDocument); - handler.RequestedPaths.Should().BeEquivalentTo( - [ - "/index.json", - "/elastic/elasticsearch/main/elasticsearch-openapi.json" - ]); + handler.RequestedPaths.Should().BeEquivalentTo(["/index.json", "/elastic/elasticsearch/main/elasticsearch-openapi.json"]); collector.Errors.Should().Be(errorsBeforeResolution, string.Join("; ", collector.ErrorMessages)); A.CallTo(() => reader.ReadAsync(A._, "elasticsearch-openapi.json")).MustHaveHappenedOnceExactly(); } @@ -131,7 +145,12 @@ public async Task ResolveDocumentsForProduct_NoLocalSpecAndIndexUnreachable_Retu var collector = new DiagnosticsCollector([]); var stack = TestHelpers.CreateStackVersionsConfiguration(currentMajor: 9); var product = TestHelpers.CreateProduct("elasticsearch", stack.GetVersioningSystem(VersioningSystemId.Stack)); - var git = new GitCheckoutInformation { Branch = "main", Remote = "https://github.com/elastic/elasticsearch.git", Ref = "refs/heads/main" }; + var git = new GitCheckoutInformation + { + Branch = "main", + Remote = "https://github.com/elastic/elasticsearch.git", + Ref = "refs/heads/main" + }; var context = CreateContext(collector, stack, git: git); var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); @@ -142,11 +161,12 @@ public async Task ResolveDocumentsForProduct_NoLocalSpecAndIndexUnreachable_Retu context, NoopMarkdownStringRenderer.Instance, versionIndexClient, - reader); + reader + ); var errorsBeforeResolution = collector.Errors; - var documents = await generator.ResolveDocumentsForProduct( - "elasticsearch", ApiConfig(product), TestContext.Current.CancellationToken); + var documents = + await generator.ResolveDocumentsForProduct("elasticsearch", ApiConfig(product), TestContext.Current.CancellationToken); documents.Should().BeEmpty(); collector.Errors.Should().BeGreaterThan(errorsBeforeResolution); @@ -154,20 +174,22 @@ public async Task ResolveDocumentsForProduct_NoLocalSpecAndIndexUnreachable_Retu A.CallTo(() => reader.ReadAsync(A._, A._)).MustNotHaveHappened(); } - private static OpenApiDocument SpecDocument() => new() - { - Info = new OpenApiInfo { Title = "Elasticsearch API", Version = "9.4" } - }; + private static OpenApiDocument SpecDocument() => new() { Info = new OpenApiInfo { Title = "Elasticsearch API", Version = "9.4" } }; private static HttpResponseMessage IndexResponse(string body) => new(HttpStatusCode.OK) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") }; - private static HttpResponseMessage SpecResponse() => new(HttpStatusCode.OK) - { - Content = new StringContent( - /*lang=json,strict*/ """{"openapi":"3.1.0","info":{"title":"Elasticsearch API","version":"9.4"},"paths":{}}""", - System.Text.Encoding.UTF8, "application/json") - }; + private static HttpResponseMessage SpecResponse() => + new(HttpStatusCode.OK) + { + Content = + new StringContent( + /*lang=json,strict*/ + """{"openapi":"3.1.0","info":{"title":"Elasticsearch API","version":"9.4"},"paths":{}}""", + System.Text.Encoding.UTF8, + "application/json" + ) + }; private sealed class StubHandler(Func responder) : HttpMessageHandler { diff --git a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs index 7eff1c5823..7b30d4db3a 100644 --- a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs @@ -32,18 +32,24 @@ private static BuildContext CreateContext( DiagnosticsCollector collector, VersionsConfiguration? versionsConfiguration = null, ProductsConfiguration? productsConfiguration = null, - GitCheckoutInformation? git = null) - { - return new BuildContext(collector, - DocumentationFileSystem.Resolve(new FileSystem().DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), new DocumentationScopeOptions { Git = git }), - TestHelpers.CreateConfigurationContext(new FileSystem(), versionsConfiguration, productsConfiguration)); - } + GitCheckoutInformation? git = null + ) => + new BuildContext( + collector, + DocumentationFileSystem.Resolve( + new FileSystem().DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), + new DocumentationScopeOptions { Git = git } + ), + TestHelpers.CreateConfigurationContext(new FileSystem(), versionsConfiguration, productsConfiguration) + ); private static ResolvedApiConfiguration ApiConfig( Product product, IFileInfo? localSpecFile = null, string specFileName = "elasticsearch-openapi.json", - string? repository = null) => new() + string? repository = null + ) => + new() { ProductKey = product.Id, Product = product, @@ -57,29 +63,33 @@ private static ProductsConfiguration ProductsFor(params Product[] products) => { Products = products.ToFrozenDictionary(p => p.Id, StringComparer.OrdinalIgnoreCase), PublicReferenceProducts = products.ToFrozenDictionary(p => p.Id, StringComparer.OrdinalIgnoreCase), - ProductDisplayNames = products.ToDictionary(p => p.Id, p => p.DisplayName, StringComparer.OrdinalIgnoreCase).ToFrozenDictionary(StringComparer.OrdinalIgnoreCase) + ProductDisplayNames = + products.ToDictionary(p => p.Id, p => p.DisplayName, StringComparer.OrdinalIgnoreCase).ToFrozenDictionary( + StringComparer.OrdinalIgnoreCase + ) }; - private static OpenApiDocument SpecDocument(string title) => new() - { - Info = new OpenApiInfo { Title = title, Version = "1.0" }, - Paths = new OpenApiPaths + private static OpenApiDocument SpecDocument(string title) => + new() { - ["/ping"] = new OpenApiPathItem + Info = new OpenApiInfo { Title = title, Version = "1.0" }, + Paths = new OpenApiPaths { - Operations = new Dictionary + ["/ping"] = new OpenApiPathItem { - [HttpMethod.Get] = new() + Operations = new Dictionary { - OperationId = "ping", - Tags = new HashSet { new("core") }, - Responses = new OpenApiResponses { ["200"] = new OpenApiResponse { Description = "ok" } } + [HttpMethod.Get] = new() + { + OperationId = "ping", + Tags = new HashSet { new("core") }, + Responses = new OpenApiResponses { ["200"] = new OpenApiResponse { Description = "ok" } } + } } } - } - }, - Tags = new HashSet { new() { Name = "core" } } - }; + }, + Tags = new HashSet { new() { Name = "core" } } + }; [Fact] public async Task ResolveDocumentsForProduct_MultiMajorIndex_ResolvesMainAndNumericVersions() @@ -93,22 +103,30 @@ public async Task ResolveDocumentsForProduct_MultiMajorIndex_ResolvesMainAndNume var reader = CreateSequentialReader( SpecDocument("Elasticsearch main"), SpecDocument("Elasticsearch 9"), - SpecDocument("Elasticsearch 8")); + SpecDocument("Elasticsearch 8") + ); var generator = CreateGenerator(context, versionIndexClient, reader); - var documents = await generator.ResolveDocumentsForProduct("elasticsearch", ApiConfig(product), TestContext.Current.CancellationToken); + var documents = + await generator.ResolveDocumentsForProduct("elasticsearch", ApiConfig(product), TestContext.Current.CancellationToken); documents.Should().HaveCount(3); documents.Select(d => d.Version.Moniker).Should().BeEquivalentTo(["main", "9", "8"]); - documents.Should().ContainSingle(d => - ApiUrlBuilder.ProductSuffix("elasticsearch", d.Version.Moniker) == "elasticsearch" - && d.Document.Info.Title == "Elasticsearch main"); - documents.Should().ContainSingle(d => - ApiUrlBuilder.ProductSuffix("elasticsearch", d.Version.Moniker) == "elasticsearch/v9" - && d.Document.Info.Title == "Elasticsearch 9"); - documents.Should().ContainSingle(d => - ApiUrlBuilder.ProductSuffix("elasticsearch", d.Version.Moniker) == "elasticsearch/v8" - && d.Document.Info.Title == "Elasticsearch 8"); + documents.Should().ContainSingle( + d => + ApiUrlBuilder.ProductSuffix("elasticsearch", d.Version.Moniker) == "elasticsearch" && + d.Document.Info.Title == "Elasticsearch main" + ); + documents.Should().ContainSingle( + d => + ApiUrlBuilder.ProductSuffix("elasticsearch", d.Version.Moniker) == "elasticsearch/v9" && + d.Document.Info.Title == "Elasticsearch 9" + ); + documents.Should().ContainSingle( + d => + ApiUrlBuilder.ProductSuffix("elasticsearch", d.Version.Moniker) == "elasticsearch/v8" && + d.Document.Info.Title == "Elasticsearch 8" + ); } [Fact] @@ -118,10 +136,18 @@ public async Task ResolveDocumentsForProduct_VersionlessProduct_RendersMainOnly( var versionless = TestHelpers.CreateVersionlessConfiguration(); var product = TestHelpers.CreateProduct("cloud-serverless", versionless.GetVersioningSystem(VersioningSystemId.Serverless)); var context = CreateContext(collector, versionless, ProductsFor(product), GitForElasticsearch()); - using var versionIndexClient = new VersionIndexClient(BaseUri, MultiVersionHandler(repository: "elastic/serverless-api-specification"), sleep: (_, _) => Task.CompletedTask); + using var versionIndexClient = new VersionIndexClient( + BaseUri, + MultiVersionHandler(repository: "elastic/serverless-api-specification"), + sleep: (_, _) => Task.CompletedTask + ); var reader = CreateSequentialReader(SpecDocument("Serverless main")); var generator = CreateGenerator(context, versionIndexClient, reader); - var apiConfig = ApiConfig(product, specFileName: "elastic-cloud-serverless.yml", repository: "elastic/serverless-api-specification"); + var apiConfig = ApiConfig( + product, + specFileName: "elastic-cloud-serverless.yml", + repository: "elastic/serverless-api-specification" + ); var documents = await generator.ResolveDocumentsForProduct("cloud-serverless", apiConfig, TestContext.Current.CancellationToken); @@ -137,16 +163,24 @@ public async Task ResolveDocumentsForProduct_LocalMainAndRemoteHistoricalVersion var stack = TestHelpers.CreateStackVersionsConfiguration(currentMajor: 9); var product = TestHelpers.CreateProduct("elasticsearch", stack.GetVersioningSystem(VersioningSystemId.Stack)); var context = CreateContext(collector, stack, ProductsFor(product), GitForElasticsearch()); - var localFile = new FileSystem().FileInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "elasticsearch-openapi-docs.json")); + var localFile = new FileSystem().FileInfo.New( + Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "elasticsearch-openapi-docs.json") + ); var localDocument = SpecDocument("Elasticsearch local main"); using var versionIndexClient = new VersionIndexClient(BaseUri, MultiVersionHandler(), sleep: (_, _) => Task.CompletedTask); var reader = A.Fake(); A.CallTo(() => reader.ReadAsync(localFile)).Returns(localDocument); - A.CallTo(() => reader.ReadAsync(A._, "elasticsearch-openapi.json")) - .ReturnsLazily((Stream _, string _) => SpecDocument("Elasticsearch remote")); + A.CallTo(() => reader.ReadAsync(A._, "elasticsearch-openapi.json")).ReturnsLazily( + (Stream _, string _) => SpecDocument("Elasticsearch remote") + ); var generator = CreateGenerator(context, versionIndexClient, reader); - var documents = await generator.ResolveDocumentsForProduct("elasticsearch", ApiConfig(product, localFile), TestContext.Current.CancellationToken); + var documents = + await generator.ResolveDocumentsForProduct( + "elasticsearch", + ApiConfig(product, localFile), + TestContext.Current.CancellationToken + ); documents.Should().HaveCount(3); documents.Should().ContainSingle(d => d.Version.Moniker == "main" && d.Document == localDocument); @@ -188,7 +222,8 @@ public async Task Generate_WritesDistinctOutputTreesForMainAndReleasedMajors() var reader = CreateSequentialReader( SpecDocument("Elasticsearch main"), SpecDocument("Elasticsearch 9"), - SpecDocument("Elasticsearch 8")); + SpecDocument("Elasticsearch 8") + ); var generator = CreateGenerator(context, versionIndexClient, reader); await generator.Generate(TestContext.Current.CancellationToken); @@ -196,7 +231,11 @@ public async Task Generate_WritesDistinctOutputTreesForMainAndReleasedMajors() context.WriteFileSystem.File.Exists(Path.Join(outputRoot, "api", "doc", "elasticsearch", "index.html")).Should().BeTrue(); context.WriteFileSystem.File.Exists(Path.Join(outputRoot, "api", "doc", "elasticsearch", "v9", "index.html")).Should().BeTrue(); context.WriteFileSystem.File.Exists(Path.Join(outputRoot, "api", "doc", "elasticsearch", "v8", "index.html")).Should().BeTrue(); - context.WriteFileSystem.File.Exists(Path.Join(outputRoot, "api", "doc", "elasticsearch", "v8", "operation", "operation-ping", "index.html")).Should().BeTrue(); + context.WriteFileSystem + .File + .Exists(Path.Join(outputRoot, "api", "doc", "elasticsearch", "v8", "operation", "operation-ping", "index.html")) + .Should() + .BeTrue(); } private static BuildContext CreateGenerateContext( @@ -204,11 +243,13 @@ private static BuildContext CreateGenerateContext( VersionsConfiguration versionsConfiguration, ProductsConfiguration productsConfiguration, string outputRoot, - GitCheckoutInformation git) + GitCheckoutInformation git + ) { var repoRoot = Path.Join(Paths.WorkingDirectoryRoot.FullName, $"api-multi-version-test-{Guid.NewGuid():N}"); var configPath = Path.Join(repoRoot, "docs", "docset.yml"); - var docsetYaml = """ + var docsetYaml = + """ api: elasticsearch: - spec: elasticsearch-openapi.json @@ -219,26 +260,27 @@ private static BuildContext CreateGenerateContext( fs.AddFile(configPath, new MockFileData(docsetYaml)); var configurationContext = TestHelpers.CreateConfigurationContext(fs, versionsConfiguration, productsConfiguration); - return new BuildContext(collector, - DocumentationFileSystem.Resolve(repoRoot, new DocumentationScopeOptions - { - ConfigurationFile = configPath, - Output = outputRoot, - Git = git, - Inner = fs - }), - configurationContext); + return new BuildContext( + collector, + DocumentationFileSystem.Resolve( + repoRoot, + new DocumentationScopeOptions { ConfigurationFile = configPath, Output = outputRoot, Git = git, Inner = fs } + ), + configurationContext + ); } - private static OpenApiGenerator CreateGenerator(BuildContext context, VersionIndexClient versionIndexClient, IOpenApiSpecificationReader reader) => - new(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance, versionIndexClient, reader); + private static OpenApiGenerator CreateGenerator( + BuildContext context, + VersionIndexClient versionIndexClient, + IOpenApiSpecificationReader reader + ) => new(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance, versionIndexClient, reader); private static IOpenApiSpecificationReader CreateSequentialReader(params OpenApiDocument[] documents) { var queue = new Queue(documents); var reader = A.Fake(); - A.CallTo(() => reader.ReadAsync(A._, A._)) - .ReturnsLazily(_ => Task.FromResult(queue.Dequeue())); + A.CallTo(() => reader.ReadAsync(A._, A._)).ReturnsLazily(_ => Task.FromResult(queue.Dequeue())); return reader; } @@ -247,7 +289,8 @@ private static HttpMessageHandler MultiVersionHandler(string repository = "elast { if (request.RequestUri!.AbsolutePath.EndsWith("index.json", StringComparison.Ordinal)) { - return IndexResponse(/*lang=json,strict*/ $$""" + return IndexResponse(/*lang=json,strict*/ + $$""" { "{{repository}}": { "elasticsearch-openapi.json": { @@ -261,28 +304,30 @@ private static HttpMessageHandler MultiVersionHandler(string repository = "elast } } } - """); + """ + ); } return SpecResponse(); }); - private static GitCheckoutInformation GitForElasticsearch() => new() - { - Branch = "main", - Remote = "https://github.com/elastic/elasticsearch.git", - Ref = "refs/heads/main" - }; + private static GitCheckoutInformation GitForElasticsearch() => + new() { Branch = "main", Remote = "https://github.com/elastic/elasticsearch.git", Ref = "refs/heads/main" }; private static HttpResponseMessage IndexResponse(string body) => new(HttpStatusCode.OK) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") }; - private static HttpResponseMessage SpecResponse() => new(HttpStatusCode.OK) - { - Content = new StringContent( - /*lang=json,strict*/ """{"openapi":"3.1.0","info":{"title":"Spec","version":"1.0"},"paths":{}}""", - System.Text.Encoding.UTF8, "application/json") - }; + private static HttpResponseMessage SpecResponse() => + new(HttpStatusCode.OK) + { + Content = + new StringContent( + /*lang=json,strict*/ + """{"openapi":"3.1.0","info":{"title":"Spec","version":"1.0"},"paths":{}}""", + System.Text.Encoding.UTF8, + "application/json" + ) + }; private sealed class StubHandler(Func responder) : HttpMessageHandler { diff --git a/tests/Elastic.ApiExplorer.Tests/OpenApiOperationIdSearchTitleTests.cs b/tests/Elastic.ApiExplorer.Tests/OpenApiOperationIdSearchTitleTests.cs index 40a252bc91..b561511d9a 100644 --- a/tests/Elastic.ApiExplorer.Tests/OpenApiOperationIdSearchTitleTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OpenApiOperationIdSearchTitleTests.cs @@ -30,23 +30,20 @@ public class OpenApiOperationIdSearchTitleTests } }; - private static OpenApiDocument CreateBulkSpec() => new() - { - Paths = new OpenApiPaths + private static OpenApiDocument CreateBulkSpec() => + new() { - ["/_bulk"] = new OpenApiPathItem + Paths = new OpenApiPaths { - Operations = new Dictionary + ["/_bulk"] = new OpenApiPathItem { - [HttpMethod.Put] = new OpenApiOperation + Operations = new Dictionary { - OperationId = "_bulk", - Summary = "Bulk index or delete documents" + [HttpMethod.Put] = new OpenApiOperation { OperationId = "_bulk", Summary = "Bulk index or delete documents" } } } } - } - }; + }; [Fact] public void BulkOperation_SearchTitleContainsTheRawOperationIdWithUnderscore() @@ -63,30 +60,30 @@ public void BulkOperation_SearchTitleContainsTheRawOperationIdWithUnderscore() doc.SearchTitle.Should().Contain("_bulk"); } - private static OpenApiDocument CreateSpecWithSummaryWhitespace(string summary) => new() - { - Paths = new OpenApiPaths + private static OpenApiDocument CreateSpecWithSummaryWhitespace(string summary) => + new() { - ["/_bulk"] = new OpenApiPathItem + Paths = new OpenApiPaths { - Operations = new Dictionary + ["/_bulk"] = new OpenApiPathItem { - [HttpMethod.Put] = new OpenApiOperation + Operations = new Dictionary { - OperationId = "_bulk", - Summary = summary + [HttpMethod.Put] = new OpenApiOperation { OperationId = "_bulk", Summary = summary } } } } - } - }; + }; [Fact] public void Operation_SummaryWithTrailingNewline_DoesNotLeakIntoTitleOrSearchTitle() { var exporter = new OpenApiDocumentExporter(VersionsConfiguration); - var docs = exporter.ConvertToDocuments(CreateSpecWithSummaryWhitespace("Bulk index or delete documents\n"), "elasticsearch").ToArray(); + var docs = exporter.ConvertToDocuments( + CreateSpecWithSummaryWhitespace("Bulk index or delete documents\n"), + "elasticsearch" + ).ToArray(); docs.Should().HaveCount(1); var doc = docs[0]; diff --git a/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs b/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs index 88672a7060..2d328d8f1b 100644 --- a/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs @@ -35,7 +35,7 @@ public async Task Reads() } [Theory] - [InlineData("json", /*lang=json,strict*/ """{"openapi":"3.1.0","info":{"title":"Test","version":"1.0"},"paths":{}}""")] + [InlineData("json", /*lang=json,strict*/ """{"openapi":"3.1.0","info":{"title":"Test","version":"1.0"},"paths":{}}""")] [InlineData("yaml", "openapi: 3.1.0\ninfo:\n title: Test\n version: 1.0\npaths: {}")] public async Task ReadsStream(string extension, string specification) { @@ -52,7 +52,11 @@ public async Task Navigation() { var collector = new DiagnosticsCollector([]); var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem()); - var context = new BuildContext(collector, DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName), configurationContext); + var context = new BuildContext( + collector, + DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName), + configurationContext + ); var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance); var openApiDocument = await OpenApiReader.Instance.ReadAsync(LocalSpecFile()); diff --git a/tests/Elastic.ApiExplorer.Tests/SimpleMarkdownNavigationItemTests.cs b/tests/Elastic.ApiExplorer.Tests/SimpleMarkdownNavigationItemTests.cs index aa1bbe4606..4d79ebfbc3 100644 --- a/tests/Elastic.ApiExplorer.Tests/SimpleMarkdownNavigationItemTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/SimpleMarkdownNavigationItemTests.cs @@ -34,18 +34,15 @@ public void CreateSlugFromFile_GeneratesCorrectSlug(string fileName, string expe [InlineData("operation", "operation")] public void ValidateSlugForCollisions_ThrowsForReservedSegments(string slug, string reservedSegment) { - var act = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions( - slug, "elasticsearch", "/docs/file.md"); + var act = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions(slug, "elasticsearch", "/docs/file.md"); - act.Should().Throw() - .WithMessage($"*conflicts with reserved API Explorer segment*{reservedSegment}*"); + act.Should().Throw().WithMessage($"*conflicts with reserved API Explorer segment*{reservedSegment}*"); } [Fact] public void ValidateSlugForCollisions_AllowsSlugThatMatchesOperationId() { - var act = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions( - "search", "elasticsearch", "/docs/search.md"); + var act = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions("search", "elasticsearch", "/docs/search.md"); act.Should().NotThrow(); } @@ -53,8 +50,7 @@ public void ValidateSlugForCollisions_AllowsSlugThatMatchesOperationId() [Fact] public void ValidateSlugForCollisions_AllowsValidSlug() { - var act = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions( - "overview", "elasticsearch", "/docs/overview.md"); + var act = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions("overview", "elasticsearch", "/docs/overview.md"); act.Should().NotThrow(); } diff --git a/tests/Elastic.ApiExplorer.Tests/TagMetadataTests.cs b/tests/Elastic.ApiExplorer.Tests/TagMetadataTests.cs index a8016491c6..ee6bb9aaeb 100644 --- a/tests/Elastic.ApiExplorer.Tests/TagMetadataTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/TagMetadataTests.cs @@ -25,7 +25,8 @@ public class TagMetadataTests public async Task ApiTag_WithXDisplayName_UsesDisplayNameForNavigation() { // Arrange - minimal OpenAPI spec with x-displayName and multiple tags to trigger TagNavigationItem creation - var openApiJson = /*lang=json,strict*/ """ + var openApiJson = /*lang=json,strict*/ + """ { "openapi": "3.0.3", "info": { @@ -98,7 +99,8 @@ public async Task ApiTag_WithXDisplayName_UsesDisplayNameForNavigation() public async Task ApiTag_WithoutXDisplayName_FallsBackToCanonicalName() { // Arrange - spec without x-displayName, multiple tags to trigger TagNavigationItem creation - var openApiJson = /*lang=json,strict*/ """ + var openApiJson = /*lang=json,strict*/ + """ { "openapi": "3.0.3", "info": { @@ -157,7 +159,8 @@ public async Task ApiTag_WithoutXDisplayName_FallsBackToCanonicalName() public async Task ApiTag_WithMultipleTagsAndDisplayNames_ParsesCorrectly() { // Arrange - multiple tags with different display name scenarios - var openApiJson = /*lang=json,strict*/ """ + var openApiJson = /*lang=json,strict*/ + """ { "openapi": "3.0.3", "info": { @@ -227,7 +230,8 @@ public async Task ApiTag_WithMultipleTagsAndDisplayNames_ParsesCorrectly() public async Task ApiTag_StableNavigationIds_UsesCanonicalTagName() { // Arrange - tag where display name differs significantly from canonical name, multiple tags for TagNavigationItem creation - var openApiJson = /*lang=json,strict*/ """ + var openApiJson = /*lang=json,strict*/ + """ { "openapi": "3.0.3", "info": { @@ -285,7 +289,11 @@ public async Task ApiTag_StableNavigationIds_UsesCanonicalTagName() { var collector = new DiagnosticsCollector([]); var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem()); - var context = new BuildContext(collector, DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName), configurationContext); + var context = new BuildContext( + collector, + DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName), + configurationContext + ); var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance); @@ -339,7 +347,8 @@ private static bool IsTagForName(TagNavigationItem tagItem, string expectedTagNa public async Task Tags_WithMixedDisplayNames_SortedAlphabeticallyByDisplayName() { // Arrange - spec with mixed x-displayName and canonical names - var openApiJson = /*lang=json*/ """ + var openApiJson = /*lang=json*/ + """ { "openapi": "3.0.3", "info": { @@ -406,7 +415,8 @@ public async Task Tags_WithMixedDisplayNames_SortedAlphabeticallyByDisplayName() public async Task Tags_CaseInsensitiveSorting_WorksCorrectly() { // Arrange - spec with case variations - var openApiJson = /*lang=json*/ """ + var openApiJson = /*lang=json*/ + """ { "openapi": "3.0.3", "info": { @@ -459,7 +469,8 @@ public async Task Tags_CaseInsensitiveSorting_WorksCorrectly() public async Task Tags_OnlyCanonicalNames_SortedAlphabetically() { // Arrange - spec with no x-displayName values - var openApiJson = /*lang=json*/ """ + var openApiJson = /*lang=json*/ + """ { "openapi": "3.0.3", "info": { @@ -524,7 +535,8 @@ public async Task Tags_OnlyCanonicalNames_SortedAlphabetically() public async Task Tags_WithinClassification_SortedCorrectly() { // Arrange - x-tagGroups (Redocly-style) drives classification; sort tags by display name within a group - var openApiJson = /*lang=json,strict*/ """ + var openApiJson = /*lang=json,strict*/ + """ { "openapi": "3.0.3", "info": { @@ -617,7 +629,8 @@ public async Task Tags_WithinClassification_SortedCorrectly() [Fact] public async Task XTagGroups_Classification_Url_PointsToApiOverview_NotFirstTag() { - var openApiJson = /*lang=json,strict*/ """ + var openApiJson = /*lang=json,strict*/ + """ { "openapi": "3.0.3", "info": { "title": "ES", "version": "1.0" }, @@ -655,7 +668,8 @@ public async Task XTagGroups_Classification_Url_PointsToApiOverview_NotFirstTag( [Fact] public async Task WithoutXTagGroups_ElasticsearchTitle_UsesFlatTagNavigation() { - var openApiJson = /*lang=json,strict*/ """ + var openApiJson = /*lang=json,strict*/ + """ { "openapi": "3.0.3", "info": { @@ -695,7 +709,8 @@ public async Task WithoutXTagGroups_ElasticsearchTitle_UsesFlatTagNavigation() [Fact] public async Task XTagGroups_ClassificationOrder_FollowsSpecOrder() { - var openApiJson = /*lang=json,strict*/ """ + var openApiJson = /*lang=json,strict*/ + """ { "openapi": "3.0.3", "info": { "title": "Order Test", "version": "1.0.0" }, @@ -738,10 +753,7 @@ public async Task XTagGroups_ClassificationOrder_FollowsSpecOrder() var (generator, openApiDocument) = await CreateGeneratorWithSpec(openApiJson); var navigation = generator.CreateNavigation("test", openApiDocument); - var titles = navigation.NavigationItems - .OfType() - .Select(c => c.NavigationTitle) - .ToList(); + var titles = navigation.NavigationItems.OfType().Select(c => c.NavigationTitle).ToList(); titles.Should().Equal("Z Group", "A Group", "B Group"); } @@ -750,7 +762,8 @@ public async Task XTagGroups_ClassificationOrder_FollowsSpecOrder() public async Task XTagGroups_OrphanTag_AssignsUnknownGroup() { // Two unlisted tags so the "unknown" classification has multiple tags; each is still a TagNavigationItem. - var openApiJson = /*lang=json,strict*/ """ + var openApiJson = /*lang=json,strict*/ + """ { "openapi": "3.0.3", "info": { "title": "Orphan Test", "version": "1.0.0" }, @@ -799,8 +812,7 @@ public async Task XTagGroups_OrphanTag_AssignsUnknownGroup() var unknownTags = classifications[1].NavigationItems.OfType().ToList(); unknownTags.Should().HaveCount(2); - unknownTags - .Select(t => t.Index.Model.Name) + unknownTags.Select(t => t.Index.Model.Name) .OrderBy(name => name, StringComparer.Ordinal) .Should() .Equal("not_in_any_group", "other_orphan"); @@ -809,7 +821,8 @@ public async Task XTagGroups_OrphanTag_AssignsUnknownGroup() [Fact] public async Task Single_Tag_Still_Creates_TagNavigationItem() { - var openApiJson = /*lang=json,strict*/ """ + var openApiJson = /*lang=json,strict*/ + """ { "openapi": "3.0.3", "info": { "title": "Solo", "version": "1.0" }, @@ -840,34 +853,27 @@ public async Task Single_Tag_Still_Creates_TagNavigationItem() } [Fact] - public void GenerateTagMoniker_DataStream_Uses_Hyphen() - { - ApiUrlBuilder.TagMoniker("data stream").Should().Be("endpoint-data-stream"); - } + public void GenerateTagMoniker_DataStream_Uses_Hyphen() => ApiUrlBuilder.TagMoniker("data stream").Should().Be("endpoint-data-stream"); [Theory] [InlineData("bulk", "/_bulk", "operation-bulk")] [InlineData("cat-aliases", "/_cat/aliases", "operation-cat-aliases")] [InlineData(null, "/indices/{index}/_search", "operation-indices-index-_search")] - public void OperationMoniker_MatchesBumpShScheme(string? operationId, string route, string expected) - { + public void OperationMoniker_MatchesBumpShScheme(string? operationId, string route, string expected) => ApiUrlBuilder.OperationMoniker(operationId, route).Should().Be(expected); - } [Theory] [InlineData("cat", "endpoint-cat")] [InlineData("health_report", "endpoint-health_report")] [InlineData("APM agent configuration", "endpoint-apm-agent-configuration")] [InlineData("Elastic Package Manager (EPM)", "endpoint-elastic-package-manager-epm")] - public void TagMoniker_MatchesBumpShScheme(string tagName, string expected) - { - ApiUrlBuilder.TagMoniker(tagName).Should().Be(expected); - } + public void TagMoniker_MatchesBumpShScheme(string tagName, string expected) => ApiUrlBuilder.TagMoniker(tagName).Should().Be(expected); [Fact] public async Task Tag_Url_Uses_Group_Segment() { - var openApiJson = /*lang=json,strict*/ """ + var openApiJson = /*lang=json,strict*/ + """ { "openapi": "3.0.3", "info": { "title": "T", "version": "1.0" }, @@ -892,7 +898,8 @@ public async Task Tag_Url_Uses_Group_Segment() [Fact] public async Task Operation_Url_Uses_Operation_Segment() { - var openApiJson = /*lang=json,strict*/ """ + var openApiJson = /*lang=json,strict*/ + """ { "openapi": "3.0.3", "info": { "title": "T", "version": "1.0" }, @@ -925,7 +932,8 @@ public async Task Operation_Url_Uses_Operation_Segment() [Fact] public async Task Tag_Landing_Parses_Description_And_ExternalDocs_Like_Elasticsearch_Connector_Tag() { - var openApiJson = /*lang=json,strict*/ """ + var openApiJson = /*lang=json,strict*/ + """ { "openapi": "3.0.3", "info": { "title": "ES", "version": "1.0" }, @@ -963,7 +971,8 @@ public async Task Tag_Landing_Parses_Description_And_ExternalDocs_Like_Elasticse [Fact] public async Task CreateNavigation_Throws_When_Two_Tag_Names_Normalize_To_Same_Url_Segment() { - var openApiJson = /*lang=json,strict*/ """ + var openApiJson = /*lang=json,strict*/ + """ { "openapi": "3.0.3", "info": { "title": "X", "version": "1.0" }, @@ -978,8 +987,6 @@ public async Task CreateNavigation_Throws_When_Two_Tag_Names_Normalize_To_Same_U var (generator, openApiDocument) = await CreateGeneratorWithSpec(openApiJson); var act = () => generator.CreateNavigation("test", openApiDocument); - act.Should() - .Throw() - .WithMessage("*tag URL segment conflict*"); + act.Should().Throw().WithMessage("*tag URL segment conflict*"); } } diff --git a/tests/Elastic.ApiExplorer.Tests/TestHelpers.cs b/tests/Elastic.ApiExplorer.Tests/TestHelpers.cs index 3b03b19924..fdee02aaea 100644 --- a/tests/Elastic.ApiExplorer.Tests/TestHelpers.cs +++ b/tests/Elastic.ApiExplorer.Tests/TestHelpers.cs @@ -22,7 +22,11 @@ namespace Elastic.ApiExplorer.Tests; public static class TestHelpers { - public static IConfigurationContext CreateConfigurationContext(IFileSystem fileSystem, VersionsConfiguration? versionsConfiguration = null, ProductsConfiguration? productsConfiguration = null) + public static IConfigurationContext CreateConfigurationContext( + IFileSystem fileSystem, + VersionsConfiguration? versionsConfiguration = null, + ProductsConfiguration? productsConfiguration = null + ) { versionsConfiguration ??= CreateStackVersionsConfiguration(currentMajor: 9, currentMinor: 0); if (productsConfiguration is null) @@ -30,7 +34,8 @@ public static IConfigurationContext CreateConfigurationContext(IFileSystem fileS var products = new Dictionary { { - "elasticsearch", new Product + "elasticsearch", + new Product { Id = "elasticsearch", DisplayName = "Elasticsearch", @@ -48,10 +53,7 @@ public static IConfigurationContext CreateConfigurationContext(IFileSystem fileS var search = new SearchConfiguration { Synonyms = [], Rules = [], DiminishTerms = [] }; return new ConfigurationContext { - Endpoints = new DocumentationEndpoints - { - Elasticsearch = ElasticsearchEndpoint.Default, - }, + Endpoints = new DocumentationEndpoints { Elasticsearch = ElasticsearchEndpoint.Default, }, ConfigurationFileProvider = new ConfigurationFileProvider(NullLoggerFactory.Instance, new ConfigurationFileSystem(fileSystem)), VersionsConfiguration = versionsConfiguration, ProductsConfiguration = productsConfiguration, @@ -66,7 +68,8 @@ public static VersionsConfiguration CreateStackVersionsConfiguration(int current VersioningSystems = new Dictionary { { - VersioningSystemId.Stack, new VersioningSystem + VersioningSystemId.Stack, + new VersioningSystem { Id = VersioningSystemId.Stack, Current = new SemVersion(currentMajor, currentMinor, patch), @@ -82,7 +85,8 @@ public static VersionsConfiguration CreateVersionlessConfiguration() => VersioningSystems = new Dictionary { { - VersioningSystemId.Serverless, new VersioningSystem + VersioningSystemId.Serverless, + new VersioningSystem { Id = VersioningSystemId.Serverless, Current = new SemVersion(VersioningSystem.VersionlessSentinel, 0, 0), @@ -93,10 +97,5 @@ public static VersionsConfiguration CreateVersionlessConfiguration() => }; public static Product CreateProduct(string id, VersioningSystem versioningSystem, string? displayName = null) => - new() - { - Id = id, - DisplayName = displayName ?? id, - VersioningSystem = versioningSystem - }; + new() { Id = id, DisplayName = displayName ?? id, VersioningSystem = versioningSystem }; } diff --git a/tests/Elastic.ApiExplorer.Tests/VersionIndexClientTests.cs b/tests/Elastic.ApiExplorer.Tests/VersionIndexClientTests.cs index 163acb923b..e835376394 100644 --- a/tests/Elastic.ApiExplorer.Tests/VersionIndexClientTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/VersionIndexClientTests.cs @@ -21,21 +21,18 @@ public class VersionIndexClientTests private static VersionIndexClient CreateClient(StubHandler handler, int maxAttempts = 3) => new(BaseUri, handler, maxAttempts, sleep: (_, _) => Task.CompletedTask); - private static GitCheckoutInformation GitFor(string? remote) => new() - { - Branch = "main", - Remote = remote ?? "unavailable", - Ref = "refs/heads/main" - }; + private static GitCheckoutInformation GitFor(string? remote) => + new() { Branch = "main", Remote = remote ?? "unavailable", Ref = "refs/heads/main" }; - private static ResolvedApiConfiguration ApiConfig(IFileInfo? localSpecFile = null, string? repository = null) => new() - { - ProductKey = "elasticsearch", - Product = new Product { Id = "elasticsearch", DisplayName = "Elasticsearch" }, - SpecFileName = "elasticsearch-openapi.json", - LocalSpecFile = localSpecFile, - Repository = repository - }; + private static ResolvedApiConfiguration ApiConfig(IFileInfo? localSpecFile = null, string? repository = null) => + new() + { + ProductKey = "elasticsearch", + Product = new Product { Id = "elasticsearch", DisplayName = "Elasticsearch" }, + SpecFileName = "elasticsearch-openapi.json", + LocalSpecFile = localSpecFile, + Repository = repository + }; private static IFileInfo LocalSpecFile() { @@ -51,7 +48,14 @@ public async Task ResolveVersionsAsync_AlwaysFetchesTheRootIndexAtBucketRoot() using var client = CreateClient(handler); var collector = new CapturingDiagnosticsCollector(); - _ = await client.ResolveVersionsAsync(GitFor("https://github.com/elastic/elasticsearch.git"), "elasticsearch", ApiConfig(), collector, TestContext.Current.CancellationToken); + _ = + await client.ResolveVersionsAsync( + GitFor("https://github.com/elastic/elasticsearch.git"), + "elasticsearch", + ApiConfig(), + collector, + TestContext.Current.CancellationToken + ); handler.RequestedPaths.Should().ContainSingle().Which.Should().Be("/index.json"); } @@ -63,7 +67,8 @@ public async Task ResolveVersionsAsync_NoLocalSpec_NoRepository_EmitsErrorAndRet using var client = CreateClient(handler); var collector = new CapturingDiagnosticsCollector(); - var versions = await client.ResolveVersionsAsync(GitFor(null), "elasticsearch", ApiConfig(), collector, TestContext.Current.CancellationToken); + var versions = + await client.ResolveVersionsAsync(GitFor(null), "elasticsearch", ApiConfig(), collector, TestContext.Current.CancellationToken); versions.Should().BeEmpty(); collector.ErrorMessages.Should().ContainSingle(m => m.Contains("its repository could not be determined")); @@ -78,9 +83,20 @@ public async Task ResolveVersionsAsync_LocalSpec_NoRepository_ReturnsLocalMainOn var collector = new CapturingDiagnosticsCollector(); var localFile = LocalSpecFile(); - var versions = await client.ResolveVersionsAsync(GitFor(null), "elasticsearch", ApiConfig(localFile), collector, TestContext.Current.CancellationToken); - - versions.Should().ContainSingle().Which.Should().Match(v => v.Moniker == "main" && v.IsLocal && v.LocalFile == localFile); + var versions = + await client.ResolveVersionsAsync( + GitFor(null), + "elasticsearch", + ApiConfig(localFile), + collector, + TestContext.Current.CancellationToken + ); + + versions.Should() + .ContainSingle() + .Which + .Should() + .Match(v => v.Moniker == "main" && v.IsLocal && v.LocalFile == localFile); collector.Errors.Should().Be(0); collector.Warnings.Should().Be(0); } @@ -88,8 +104,11 @@ public async Task ResolveVersionsAsync_LocalSpec_NoRepository_ReturnsLocalMainOn [Fact] public async Task ResolveVersionsAsync_MultipleRemoteVersions_ResolvesAllFromIndex() { - var handler = new StubHandler(_ => IndexResponse( - /*lang=json,strict*/ """ + var handler = new StubHandler( + _ => + IndexResponse( + /*lang=json,strict*/ + """ { "elastic/elasticsearch": { "elasticsearch-openapi.json": { @@ -99,14 +118,25 @@ public async Task ResolveVersionsAsync_MultipleRemoteVersions_ResolvesAllFromInd } } } - """)); + """ + ) + ); using var client = CreateClient(handler); var collector = new CapturingDiagnosticsCollector(); - var versions = await client.ResolveVersionsAsync(GitFor("https://github.com/elastic/elasticsearch.git"), "elasticsearch", ApiConfig(), collector, TestContext.Current.CancellationToken); + var versions = + await client.ResolveVersionsAsync( + GitFor("https://github.com/elastic/elasticsearch.git"), + "elasticsearch", + ApiConfig(), + collector, + TestContext.Current.CancellationToken + ); versions.Should().HaveCount(3); - versions.Should().ContainSingle(v => v.Moniker == "main" && !v.IsLocal && v.ObjectKey == "elastic/elasticsearch/main/elasticsearch-openapi.json"); + versions.Should().ContainSingle( + v => v.Moniker == "main" && !v.IsLocal && v.ObjectKey == "elastic/elasticsearch/main/elasticsearch-openapi.json" + ); versions.Should().ContainSingle(v => v.Moniker == "9" && v.Version == "9.4"); versions.Should().ContainSingle(v => v.Moniker == "8" && v.Version == "8.19"); collector.Errors.Should().Be(0); @@ -116,8 +146,11 @@ public async Task ResolveVersionsAsync_MultipleRemoteVersions_ResolvesAllFromInd [Fact] public async Task ResolveVersionsAsync_RepositoryOverride_UsedInsteadOfGitRemote() { - var handler = new StubHandler(_ => IndexResponse( - /*lang=json,strict*/ """ + var handler = new StubHandler( + _ => + IndexResponse( + /*lang=json,strict*/ + """ { "elastic/elasticsearch-specification": { "elasticsearch-openapi.json": { @@ -125,29 +158,50 @@ public async Task ResolveVersionsAsync_RepositoryOverride_UsedInsteadOfGitRemote } } } - """)); + """ + ) + ); using var client = CreateClient(handler); var collector = new CapturingDiagnosticsCollector(); // The git remote is a docs repo that never publishes this spec; only the explicit override // (elastic/elasticsearch-specification) has a matching entry. - var versions = await client.ResolveVersionsAsync( - GitFor("https://github.com/elastic/docs-builder.git"), "elasticsearch", - ApiConfig(repository: "elastic/elasticsearch-specification"), collector, TestContext.Current.CancellationToken); - - versions.Should().ContainSingle(v => v.Moniker == "main" && v.ObjectKey == "elastic/elasticsearch-specification/main/elasticsearch-openapi.json"); + var versions = + await client.ResolveVersionsAsync( + GitFor("https://github.com/elastic/docs-builder.git"), + "elasticsearch", + ApiConfig(repository: "elastic/elasticsearch-specification"), + collector, + TestContext.Current.CancellationToken + ); + + versions.Should().ContainSingle( + v => v.Moniker == "main" && v.ObjectKey == "elastic/elasticsearch-specification/main/elasticsearch-openapi.json" + ); collector.Errors.Should().Be(0); } [Fact] public async Task ResolveVersionsAsync_RepositoryNotInIndex_NoLocalSpec_EmitsErrorAndReturnsEmpty() { - var handler = new StubHandler(_ => IndexResponse( - /*lang=json,strict*/ """{ "elastic/kibana": { "kibana.yaml": { "main": { "version": "main" } } } }""")); + var handler = new StubHandler( + _ => + IndexResponse( + /*lang=json,strict*/ + """{ "elastic/kibana": { "kibana.yaml": { "main": { "version": "main" } } } }""" + ) + ); using var client = CreateClient(handler); var collector = new CapturingDiagnosticsCollector(); - var versions = await client.ResolveVersionsAsync(GitFor("https://github.com/elastic/elasticsearch.git"), "elasticsearch", ApiConfig(), collector, TestContext.Current.CancellationToken); + var versions = + await client.ResolveVersionsAsync( + GitFor("https://github.com/elastic/elasticsearch.git"), + "elasticsearch", + ApiConfig(), + collector, + TestContext.Current.CancellationToken + ); versions.Should().BeEmpty(); collector.ErrorMessages.Should().ContainSingle(m => m.Contains("no entry for repository 'elastic/elasticsearch'")); @@ -156,8 +210,11 @@ public async Task ResolveVersionsAsync_RepositoryNotInIndex_NoLocalSpec_EmitsErr [Fact] public async Task ResolveVersionsAsync_SpecNotUnderRepository_NoLocalSpec_EmitsErrorAndReturnsEmpty() { - var handler = new StubHandler(_ => IndexResponse( - /*lang=json,strict*/ """ + var handler = new StubHandler( + _ => + IndexResponse( + /*lang=json,strict*/ + """ { "elastic/elasticsearch": { "elasticsearch-serverless.json": { @@ -165,22 +222,35 @@ public async Task ResolveVersionsAsync_SpecNotUnderRepository_NoLocalSpec_EmitsE } } } - """)); + """ + ) + ); using var client = CreateClient(handler); var collector = new CapturingDiagnosticsCollector(); - var versions = await client.ResolveVersionsAsync(GitFor("https://github.com/elastic/elasticsearch.git"), "elasticsearch", ApiConfig(), collector, TestContext.Current.CancellationToken); + var versions = + await client.ResolveVersionsAsync( + GitFor("https://github.com/elastic/elasticsearch.git"), + "elasticsearch", + ApiConfig(), + collector, + TestContext.Current.CancellationToken + ); versions.Should().BeEmpty(); - collector.ErrorMessages.Should().ContainSingle(m => - m.Contains("no entry for spec 'elasticsearch-openapi.json'") && m.Contains("elastic/elasticsearch")); + collector.ErrorMessages + .Should() + .ContainSingle(m => m.Contains("no entry for spec 'elasticsearch-openapi.json'") && m.Contains("elastic/elasticsearch")); } [Fact] public async Task ResolveVersionsAsync_LocalSpecPresent_MainUsesLocalFileNotRemoteKey() { - var handler = new StubHandler(_ => IndexResponse( - /*lang=json,strict*/ """ + var handler = new StubHandler( + _ => + IndexResponse( + /*lang=json,strict*/ + """ { "elastic/elasticsearch": { "elasticsearch-openapi.json": { @@ -189,12 +259,21 @@ public async Task ResolveVersionsAsync_LocalSpecPresent_MainUsesLocalFileNotRemo } } } - """)); + """ + ) + ); using var client = CreateClient(handler); var collector = new CapturingDiagnosticsCollector(); var localFile = LocalSpecFile(); - var versions = await client.ResolveVersionsAsync(GitFor("https://github.com/elastic/elasticsearch.git"), "elasticsearch", ApiConfig(localFile), collector, TestContext.Current.CancellationToken); + var versions = + await client.ResolveVersionsAsync( + GitFor("https://github.com/elastic/elasticsearch.git"), + "elasticsearch", + ApiConfig(localFile), + collector, + TestContext.Current.CancellationToken + ); var main = versions.Should().ContainSingle(v => v.Moniker == "main").Subject; main.IsLocal.Should().BeTrue(); @@ -213,7 +292,14 @@ public async Task ResolveVersionsAsync_EmptyIndex_NoLocalSpec_EmitsErrorAndRetur using var client = CreateClient(handler); var collector = new CapturingDiagnosticsCollector(); - var versions = await client.ResolveVersionsAsync(GitFor("https://github.com/elastic/elasticsearch.git"), "elasticsearch", ApiConfig(), collector, TestContext.Current.CancellationToken); + var versions = + await client.ResolveVersionsAsync( + GitFor("https://github.com/elastic/elasticsearch.git"), + "elasticsearch", + ApiConfig(), + collector, + TestContext.Current.CancellationToken + ); versions.Should().BeEmpty(); collector.ErrorMessages.Should().ContainSingle(m => m.Contains("declares no repositories")); @@ -227,7 +313,14 @@ public async Task ResolveVersionsAsync_EmptyIndex_WithLocalSpec_ReturnsLocalMain var collector = new CapturingDiagnosticsCollector(); var localFile = LocalSpecFile(); - var versions = await client.ResolveVersionsAsync(GitFor("https://github.com/elastic/elasticsearch.git"), "elasticsearch", ApiConfig(localFile), collector, TestContext.Current.CancellationToken); + var versions = + await client.ResolveVersionsAsync( + GitFor("https://github.com/elastic/elasticsearch.git"), + "elasticsearch", + ApiConfig(localFile), + collector, + TestContext.Current.CancellationToken + ); versions.Should().ContainSingle().Which.Should().Match(v => v.Moniker == "main" && v.IsLocal); collector.WarningMessages.Should().ContainSingle(m => m.Contains("declares no repositories")); @@ -240,7 +333,14 @@ public async Task ResolveVersionsAsync_IndexFetchFails_NoLocalSpec_EmitsErrorAnd using var client = CreateClient(handler); var collector = new CapturingDiagnosticsCollector(); - var versions = await client.ResolveVersionsAsync(GitFor("https://github.com/elastic/elasticsearch.git"), "elasticsearch", ApiConfig(), collector, TestContext.Current.CancellationToken); + var versions = + await client.ResolveVersionsAsync( + GitFor("https://github.com/elastic/elasticsearch.git"), + "elasticsearch", + ApiConfig(), + collector, + TestContext.Current.CancellationToken + ); versions.Should().BeEmpty(); collector.ErrorMessages.Should().ContainSingle(m => m.Contains("could not be fetched")); @@ -255,9 +355,20 @@ public async Task ResolveVersionsAsync_IndexFetchFails_WithLocalSpec_FallsBackTo var collector = new CapturingDiagnosticsCollector(); var localFile = LocalSpecFile(); - var versions = await client.ResolveVersionsAsync(GitFor("https://github.com/elastic/elasticsearch.git"), "elasticsearch", ApiConfig(localFile), collector, TestContext.Current.CancellationToken); - - versions.Should().ContainSingle().Which.Should().Match(v => v.Moniker == "main" && v.IsLocal && v.LocalFile == localFile); + var versions = + await client.ResolveVersionsAsync( + GitFor("https://github.com/elastic/elasticsearch.git"), + "elasticsearch", + ApiConfig(localFile), + collector, + TestContext.Current.CancellationToken + ); + + versions.Should() + .ContainSingle() + .Which + .Should() + .Match(v => v.Moniker == "main" && v.IsLocal && v.LocalFile == localFile); collector.Errors.Should().Be(0); collector.WarningMessages.Should().ContainSingle(m => m.Contains("could not be fetched")); } @@ -266,14 +377,26 @@ public async Task ResolveVersionsAsync_IndexFetchFails_WithLocalSpec_FallsBackTo public async Task ResolveVersionsAsync_IndexRecoversAfterRetry_ReturnsVersions() { var attempts = 0; - var handler = new StubHandler(_ => Interlocked.Increment(ref attempts) == 1 - ? new HttpResponseMessage(HttpStatusCode.InternalServerError) - : IndexResponse( - /*lang=json,strict*/ """{ "elastic/elasticsearch": { "elasticsearch-openapi.json": { "main": { "version": "main" } } } }""")); + var handler = new StubHandler( + _ => + Interlocked.Increment(ref attempts) == 1 + ? new HttpResponseMessage(HttpStatusCode.InternalServerError) + : IndexResponse( + /*lang=json,strict*/ + """{ "elastic/elasticsearch": { "elasticsearch-openapi.json": { "main": { "version": "main" } } } }""" + ) + ); using var client = CreateClient(handler); var collector = new CapturingDiagnosticsCollector(); - var versions = await client.ResolveVersionsAsync(GitFor("https://github.com/elastic/elasticsearch.git"), "elasticsearch", ApiConfig(), collector, TestContext.Current.CancellationToken); + var versions = + await client.ResolveVersionsAsync( + GitFor("https://github.com/elastic/elasticsearch.git"), + "elasticsearch", + ApiConfig(), + collector, + TestContext.Current.CancellationToken + ); versions.Should().ContainSingle(); collector.Errors.Should().Be(0); @@ -283,8 +406,11 @@ public async Task ResolveVersionsAsync_IndexRecoversAfterRetry_ReturnsVersions() [Fact] public async Task ResolveVersionsAsync_CalledForMultipleApis_FetchesTheRootIndexOnlyOnce() { - var handler = new StubHandler(_ => IndexResponse( - /*lang=json,strict*/ """ + var handler = new StubHandler( + _ => + IndexResponse( + /*lang=json,strict*/ + """ { "elastic/elasticsearch": { "elasticsearch-openapi.json": { "main": { "version": "main" } } @@ -293,12 +419,15 @@ public async Task ResolveVersionsAsync_CalledForMultipleApis_FetchesTheRootIndex "kibana.yaml": { "main": { "version": "main" } } } } - """)); + """ + ) + ); using var client = CreateClient(handler); var collector = new CapturingDiagnosticsCollector(); var git = GitFor("https://github.com/elastic/elasticsearch.git"); - var esVersions = await client.ResolveVersionsAsync(git, "elasticsearch", ApiConfig(), collector, TestContext.Current.CancellationToken); + var esVersions = + await client.ResolveVersionsAsync(git, "elasticsearch", ApiConfig(), collector, TestContext.Current.CancellationToken); var kibanaConfig = new ResolvedApiConfiguration { ProductKey = "kibana", @@ -306,20 +435,34 @@ public async Task ResolveVersionsAsync_CalledForMultipleApis_FetchesTheRootIndex SpecFileName = "kibana.yaml", Repository = "elastic/kibana" }; - var kibanaVersions = await client.ResolveVersionsAsync(git, "kibana", kibanaConfig, collector, TestContext.Current.CancellationToken); + var kibanaVersions = + await client.ResolveVersionsAsync(git, "kibana", kibanaConfig, collector, TestContext.Current.CancellationToken); esVersions.Should().ContainSingle(); kibanaVersions.Should().ContainSingle(); - handler.RequestedPaths.Should().ContainSingle().Which.Should().Be("/index.json", "the second call should reuse the first call's cached root index"); + handler.RequestedPaths + .Should() + .ContainSingle() + .Which + .Should() + .Be("/index.json", "the second call should reuse the first call's cached root index"); } [Fact] public async Task FetchSpecStreamAsync_HappyPath_ReturnsContent() { - var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(/*lang=json,strict*/ """{"openapi":"3.1.0"}""") }); + var handler = new StubHandler( + _ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(/*lang=json,strict*/ """{"openapi":"3.1.0"}""") } + ); using var client = CreateClient(handler); var collector = new CapturingDiagnosticsCollector(); - var version = new ResolvedApiVersion { Moniker = "8", Version = "8.19", IsLocal = false, ObjectKey = "elastic/elasticsearch/8.19/elasticsearch-openapi.json" }; + var version = new ResolvedApiVersion + { + Moniker = "8", + Version = "8.19", + IsLocal = false, + ObjectKey = "elastic/elasticsearch/8.19/elasticsearch-openapi.json" + }; var stream = await client.FetchSpecStreamAsync("elasticsearch", version, collector, TestContext.Current.CancellationToken); @@ -336,7 +479,13 @@ public async Task FetchSpecStreamAsync_PersistentFailure_EmitsWarningAndReturnsN var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); using var client = CreateClient(handler); var collector = new CapturingDiagnosticsCollector(); - var version = new ResolvedApiVersion { Moniker = "8", Version = "8.19", IsLocal = false, ObjectKey = "elastic/elasticsearch/8.19/elasticsearch-openapi.json" }; + var version = new ResolvedApiVersion + { + Moniker = "8", + Version = "8.19", + IsLocal = false, + ObjectKey = "elastic/elasticsearch/8.19/elasticsearch-openapi.json" + }; var stream = await client.FetchSpecStreamAsync("elasticsearch", version, collector, TestContext.Current.CancellationToken); diff --git a/tests/Elastic.ApiExplorer.Tests/XReqAuthTests.cs b/tests/Elastic.ApiExplorer.Tests/XReqAuthTests.cs index 7c9736f66b..efbc8e076c 100644 --- a/tests/Elastic.ApiExplorer.Tests/XReqAuthTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/XReqAuthTests.cs @@ -12,13 +12,13 @@ namespace Elastic.ApiExplorer.Tests; public class XReqAuthTests { - private static string TestDataPath(string fileName) => - Path.Join(AppContext.BaseDirectory, "TestData", fileName); + private static string TestDataPath(string fileName) => Path.Join(AppContext.BaseDirectory, "TestData", fileName); [Fact] public async Task TryGetPrerequisiteLines_MinimalOpenApi3Spec_MatchesElasticsearchShape() { - var json = /*lang=json,strict*/ """ + var json = /*lang=json,strict*/ + """ { "openapi": "3.0.0", "info": { "title": "t", "version": "1" }, @@ -40,14 +40,12 @@ public async Task TryGetPrerequisiteLines_MinimalOpenApi3Spec_MatchesElasticsear try { await File.WriteAllTextAsync(jsonPath, json, TestContext.Current.CancellationToken); - var loaded = await OpenApiDocument.LoadAsync( - jsonPath, - new OpenApiReaderSettings - { - LeaveStreamOpen = false - }, - TestContext.Current.CancellationToken - ); + var loaded = + await OpenApiDocument.LoadAsync( + jsonPath, + new OpenApiReaderSettings { LeaveStreamOpen = false }, + TestContext.Current.CancellationToken + ); var op = loaded.Document!.Paths!["/a"].Operations![HttpMethod.Get]!; var lines = OpenApiXReqAuthParser.TryGetPrerequisiteLines(op, null, "/a", "op-a"); @@ -69,7 +67,8 @@ public async Task TryGetPrerequisiteLines_MinimalOpenApi3Spec_MatchesElasticsear [Fact] public async Task TryGetPrerequisiteLines_EmptyArray_ReturnsNull() { - var json = /*lang=json,strict*/ """ + var json = /*lang=json,strict*/ + """ { "openapi": "3.0.0", "info": { "title": "t", "version": "1" }, @@ -88,18 +87,17 @@ public async Task TryGetPrerequisiteLines_EmptyArray_ReturnsNull() try { await File.WriteAllTextAsync(jsonPath, json, TestContext.Current.CancellationToken); - var loaded = await OpenApiDocument.LoadAsync( - jsonPath, - new OpenApiReaderSettings - { - LeaveStreamOpen = false - }, - TestContext.Current.CancellationToken - ); + var loaded = + await OpenApiDocument.LoadAsync( + jsonPath, + new OpenApiReaderSettings { LeaveStreamOpen = false }, + TestContext.Current.CancellationToken + ); var op = loaded.Document!.Paths!["/a"].Operations![HttpMethod.Get]!; OpenApiXReqAuthParser.TryGetPrerequisiteLines(op, null, "/a", "op-a") - .Should().BeNull("empty x-req-auth should not show Prerequisites"); + .Should() + .BeNull("empty x-req-auth should not show Prerequisites"); } finally { @@ -114,14 +112,12 @@ public async Task ElasticsearchSample_CatIndicesOperations_HaveXReqAuth() var specPath = TestDataPath("elasticsearch-x-req-auth-cat-indices-sample.json"); File.Exists(specPath).Should().BeTrue($"Fixture missing: {specPath}"); - var loaded = await OpenApiDocument.LoadAsync( - specPath, - new OpenApiReaderSettings - { - LeaveStreamOpen = false - }, - TestContext.Current.CancellationToken - ); + var loaded = + await OpenApiDocument.LoadAsync( + specPath, + new OpenApiReaderSettings { LeaveStreamOpen = false }, + TestContext.Current.CancellationToken + ); var doc = loaded.Document!; var getIndices = doc.Paths!["/_cat/indices"].Operations![HttpMethod.Get]!; var a = OpenApiXReqAuthParser.TryGetPrerequisiteLines(getIndices, null, "/_cat/indices", getIndices.OperationId); @@ -130,12 +126,7 @@ public async Task ElasticsearchSample_CatIndicesOperations_HaveXReqAuth() a.Should().OnlyContain(s => !string.IsNullOrWhiteSpace(s)); var getIndicesIndex = doc.Paths!["/_cat/indices/{index}"].Operations![HttpMethod.Get]!; - var b = OpenApiXReqAuthParser.TryGetPrerequisiteLines( - getIndicesIndex, - null, - "/_cat/indices/{index}", - getIndicesIndex.OperationId - ); + var b = OpenApiXReqAuthParser.TryGetPrerequisiteLines(getIndicesIndex, null, "/_cat/indices/{index}", getIndicesIndex.OperationId); b.Should().NotBeNull(); b!.Should().NotBeEmpty(); } @@ -146,14 +137,12 @@ public async Task KibanaStyleSample_FirstPathsLackXReqAuth() var specPath = TestDataPath("kibana-openapi-no-x-req-auth-sample.json"); File.Exists(specPath).Should().BeTrue($"Fixture missing: {specPath}"); - var loaded = await OpenApiDocument.LoadAsync( - specPath, - new OpenApiReaderSettings - { - LeaveStreamOpen = false - }, - TestContext.Current.CancellationToken - ); + var loaded = + await OpenApiDocument.LoadAsync( + specPath, + new OpenApiReaderSettings { LeaveStreamOpen = false }, + TestContext.Current.CancellationToken + ); var doc = loaded.Document!; var opCount = 0; @@ -173,12 +162,7 @@ public async Task KibanaStyleSample_FirstPathsLackXReqAuth() opLimitReached = true; break; } - var lines = OpenApiXReqAuthParser.TryGetPrerequisiteLines( - httpOp.Value, - null, - p.Key, - httpOp.Value.OperationId - ); + var lines = OpenApiXReqAuthParser.TryGetPrerequisiteLines(httpOp.Value, null, p.Key, httpOp.Value.OperationId); lines.Should().BeNull("sample Kibana-style spec has no x-req-auth on sampled operations"); opCount++; } diff --git a/tests/Elastic.Changelog.Tests/AllowlistIdentity/ScrubberAllowlistIdentityServiceTests.cs b/tests/Elastic.Changelog.Tests/AllowlistIdentity/ScrubberAllowlistIdentityServiceTests.cs index 8372d422d2..e007755b0b 100644 --- a/tests/Elastic.Changelog.Tests/AllowlistIdentity/ScrubberAllowlistIdentityServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/AllowlistIdentity/ScrubberAllowlistIdentityServiceTests.cs @@ -34,31 +34,41 @@ public class ScrubberAllowlistIdentityServiceTests(ITestOutputHelper output) private readonly MockFileSystem _fileSystem = new(); private readonly TestDiagnosticsCollector _collector = new(output); - private ScrubberAllowlistIdentityService CreateService() => - new(NullLoggerFactory.Instance, _releaseService, _fileSystem); + private ScrubberAllowlistIdentityService CreateService() => new(NullLoggerFactory.Instance, _releaseService, _fileSystem); - private static GitHubReleaseInfo Release(string tag, bool withAsset, bool draft = false) => new() - { - TagName = tag, - Draft = draft, - Assets = withAsset - ? [new GitHubReleaseAsset { Name = ScrubberAllowlistIdentity.AssetName, BrowserDownloadUrl = $"https://example/{tag}" }] - : [new GitHubReleaseAsset { Name = "docs-builder.zip", BrowserDownloadUrl = $"https://example/{tag}/zip" }] - }; + private static GitHubReleaseInfo Release(string tag, bool withAsset, bool draft = false) => + new() + { + TagName = tag, + Draft = draft, + Assets = withAsset + ? [new GitHubReleaseAsset { Name = ScrubberAllowlistIdentity.AssetName, BrowserDownloadUrl = $"https://example/{tag}" }] + : [new GitHubReleaseAsset { Name = "docs-builder.zip", BrowserDownloadUrl = $"https://example/{tag}/zip" }] + }; private void AssetDownloadReturns(string? content) => - A.CallTo(() => _releaseService.DownloadAssetTextAsync( - A.That.Matches(a => a.Name == ScrubberAllowlistIdentity.AssetName), A._)) - .Returns(Task.FromResult(content)); + A.CallTo( + () => + _releaseService.DownloadAssetTextAsync( + A.That.Matches(a => a.Name == ScrubberAllowlistIdentity.AssetName), + A._ + ) + ).Returns(Task.FromResult(content)); [Fact] public async Task ResolveDeployedAsync_LatestReleaseCarriesAsset_ResolvesIt() { - A.CallTo(() => _releaseService.FetchReleasesAsync("elastic", "docs-builder", A._, A._)) - .Returns(Task.FromResult>([Release("v2.0.0", withAsset: true)])); + A.CallTo( + () => _releaseService.FetchReleasesAsync("elastic", "docs-builder", A._, A._) + ).Returns(Task.FromResult>([Release("v2.0.0", withAsset: true)])); AssetDownloadReturns(ValidAssetJson); - var resolved = await CreateService().ResolveDeployedAsync(_collector, new ResolveScrubberAllowlistArguments(), TestContext.Current.CancellationToken); + var resolved = + await CreateService().ResolveDeployedAsync( + _collector, + new ResolveScrubberAllowlistArguments(), + TestContext.Current.CancellationToken + ); resolved.Should().NotBeNull(); resolved.ReleaseTag.Should().Be("v2.0.0"); @@ -71,12 +81,20 @@ public async Task ResolveDeployedAsync_NewestReleaseMissingAsset_FallsBackToPrev { // The newest release exists but its scrubber deploy never completed (no asset); the one // before it is the most recent gated deploy and must win. - A.CallTo(() => _releaseService.FetchReleasesAsync("elastic", "docs-builder", A._, A._)) - .Returns(Task.FromResult>( - [Release("v2.1.0", withAsset: false), Release("v2.0.0", withAsset: true)])); + A.CallTo( + () => _releaseService.FetchReleasesAsync("elastic", "docs-builder", A._, A._) + ).Returns(Task.FromResult>([ + Release("v2.1.0", withAsset: false), + Release("v2.0.0", withAsset: true) + ])); AssetDownloadReturns(ValidAssetJson); - var resolved = await CreateService().ResolveDeployedAsync(_collector, new ResolveScrubberAllowlistArguments(), TestContext.Current.CancellationToken); + var resolved = + await CreateService().ResolveDeployedAsync( + _collector, + new ResolveScrubberAllowlistArguments(), + TestContext.Current.CancellationToken + ); resolved.Should().NotBeNull(); resolved.ReleaseTag.Should().Be("v2.0.0"); @@ -85,12 +103,20 @@ public async Task ResolveDeployedAsync_NewestReleaseMissingAsset_FallsBackToPrev [Fact] public async Task ResolveDeployedAsync_DraftReleasesAreSkipped() { - A.CallTo(() => _releaseService.FetchReleasesAsync("elastic", "docs-builder", A._, A._)) - .Returns(Task.FromResult>( - [Release("v2.1.0", withAsset: true, draft: true), Release("v2.0.0", withAsset: true)])); + A.CallTo( + () => _releaseService.FetchReleasesAsync("elastic", "docs-builder", A._, A._) + ).Returns(Task.FromResult>([ + Release("v2.1.0", withAsset: true, draft: true), + Release("v2.0.0", withAsset: true) + ])); AssetDownloadReturns(ValidAssetJson); - var resolved = await CreateService().ResolveDeployedAsync(_collector, new ResolveScrubberAllowlistArguments(), TestContext.Current.CancellationToken); + var resolved = + await CreateService().ResolveDeployedAsync( + _collector, + new ResolveScrubberAllowlistArguments(), + TestContext.Current.CancellationToken + ); resolved.Should().NotBeNull(); resolved.ReleaseTag.Should().Be("v2.0.0"); @@ -99,10 +125,16 @@ public async Task ResolveDeployedAsync_DraftReleasesAreSkipped() [Fact] public async Task ResolveDeployedAsync_NoReleaseCarriesAsset_FailsWithError() { - A.CallTo(() => _releaseService.FetchReleasesAsync("elastic", "docs-builder", A._, A._)) - .Returns(Task.FromResult>([Release("v2.1.0", withAsset: false)])); + A.CallTo( + () => _releaseService.FetchReleasesAsync("elastic", "docs-builder", A._, A._) + ).Returns(Task.FromResult>([Release("v2.1.0", withAsset: false)])); - var resolved = await CreateService().ResolveDeployedAsync(_collector, new ResolveScrubberAllowlistArguments(), TestContext.Current.CancellationToken); + var resolved = + await CreateService().ResolveDeployedAsync( + _collector, + new ResolveScrubberAllowlistArguments(), + TestContext.Current.CancellationToken + ); resolved.Should().BeNull(); _collector.Diagnostics.Should().Contain(d => d.Message.Contains("cannot be resolved")); @@ -111,11 +143,16 @@ public async Task ResolveDeployedAsync_NoReleaseCarriesAsset_FailsWithError() [Fact] public async Task ResolveDeployedAsync_ExplicitTagWithoutAsset_FailsWithError() { - A.CallTo(() => _releaseService.FetchReleaseAsync("elastic", "docs-builder", "v1.0.0", A._)) - .Returns(Task.FromResult(Release("v1.0.0", withAsset: false))); + A.CallTo(() => _releaseService.FetchReleaseAsync("elastic", "docs-builder", "v1.0.0", A._)).Returns( + Task.FromResult(Release("v1.0.0", withAsset: false)) + ); - var resolved = await CreateService().ResolveDeployedAsync(_collector, - new ResolveScrubberAllowlistArguments { Tag = "v1.0.0" }, TestContext.Current.CancellationToken); + var resolved = + await CreateService().ResolveDeployedAsync( + _collector, + new ResolveScrubberAllowlistArguments { Tag = "v1.0.0" }, + TestContext.Current.CancellationToken + ); resolved.Should().BeNull(); _collector.Diagnostics.Should().Contain(d => d.Message.Contains("predates")); @@ -124,11 +161,16 @@ public async Task ResolveDeployedAsync_ExplicitTagWithoutAsset_FailsWithError() [Fact] public async Task ResolveDeployedAsync_ExplicitTagNotFound_FailsWithError() { - A.CallTo(() => _releaseService.FetchReleaseAsync("elastic", "docs-builder", "v9.9.9", A._)) - .Returns(Task.FromResult(null)); + A.CallTo(() => _releaseService.FetchReleaseAsync("elastic", "docs-builder", "v9.9.9", A._)).Returns( + Task.FromResult(null) + ); - var resolved = await CreateService().ResolveDeployedAsync(_collector, - new ResolveScrubberAllowlistArguments { Tag = "v9.9.9" }, TestContext.Current.CancellationToken); + var resolved = + await CreateService().ResolveDeployedAsync( + _collector, + new ResolveScrubberAllowlistArguments { Tag = "v9.9.9" }, + TestContext.Current.CancellationToken + ); resolved.Should().BeNull(); _collector.Diagnostics.Should().Contain(d => d.Message.Contains("was not found")); @@ -137,11 +179,19 @@ public async Task ResolveDeployedAsync_ExplicitTagNotFound_FailsWithError() [Fact] public async Task ResolveDeployedAsync_MalformedAsset_FailsWithError() { - A.CallTo(() => _releaseService.FetchReleasesAsync("elastic", "docs-builder", A._, A._)) - .Returns(Task.FromResult>([Release("v2.0.0", withAsset: true)])); - AssetDownloadReturns(/*lang=json,strict*/ """{ "schema_version": 1, "artifact": "scrubber-allowlist-identity", "allowlist_sha256": "nope", "deployment_commit": "nope" }"""); - - var resolved = await CreateService().ResolveDeployedAsync(_collector, new ResolveScrubberAllowlistArguments(), TestContext.Current.CancellationToken); + A.CallTo( + () => _releaseService.FetchReleasesAsync("elastic", "docs-builder", A._, A._) + ).Returns(Task.FromResult>([Release("v2.0.0", withAsset: true)])); + AssetDownloadReturns(/*lang=json,strict*/ + """{ "schema_version": 1, "artifact": "scrubber-allowlist-identity", "allowlist_sha256": "nope", "deployment_commit": "nope" }""" + ); + + var resolved = + await CreateService().ResolveDeployedAsync( + _collector, + new ResolveScrubberAllowlistArguments(), + TestContext.Current.CancellationToken + ); resolved.Should().BeNull(); _collector.Diagnostics.Should().Contain(d => d.Message.Contains("Invalid allowlist identity")); @@ -152,13 +202,18 @@ public async Task ResolveDeployedAsync_LocalAssemblerMatches_ReportsMatch() { // sha256 of "hello\n" const string helloSha = "sha256:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"; - A.CallTo(() => _releaseService.FetchReleasesAsync("elastic", "docs-builder", A._, A._)) - .Returns(Task.FromResult>([Release("v2.0.0", withAsset: true)])); + A.CallTo( + () => _releaseService.FetchReleasesAsync("elastic", "docs-builder", A._, A._) + ).Returns(Task.FromResult>([Release("v2.0.0", withAsset: true)])); AssetDownloadReturns(ValidAssetJson.Replace(ValidSha, helloSha)); _fileSystem.AddFile("/repo/config/assembler.yml", new MockFileData("hello\n")); - var resolved = await CreateService().ResolveDeployedAsync(_collector, - new ResolveScrubberAllowlistArguments { AssemblerPath = "/repo/config/assembler.yml" }, TestContext.Current.CancellationToken); + var resolved = + await CreateService().ResolveDeployedAsync( + _collector, + new ResolveScrubberAllowlistArguments { AssemblerPath = "/repo/config/assembler.yml" }, + TestContext.Current.CancellationToken + ); resolved.Should().NotBeNull(); resolved.LocalSha256.Should().Be(helloSha); @@ -168,13 +223,18 @@ public async Task ResolveDeployedAsync_LocalAssemblerMatches_ReportsMatch() [Fact] public async Task ResolveDeployedAsync_LocalAssemblerDiffers_WarnsButResolves() { - A.CallTo(() => _releaseService.FetchReleasesAsync("elastic", "docs-builder", A._, A._)) - .Returns(Task.FromResult>([Release("v2.0.0", withAsset: true)])); + A.CallTo( + () => _releaseService.FetchReleasesAsync("elastic", "docs-builder", A._, A._) + ).Returns(Task.FromResult>([Release("v2.0.0", withAsset: true)])); AssetDownloadReturns(ValidAssetJson); _fileSystem.AddFile("/repo/config/assembler.yml", new MockFileData("different content\n")); - var resolved = await CreateService().ResolveDeployedAsync(_collector, - new ResolveScrubberAllowlistArguments { AssemblerPath = "/repo/config/assembler.yml" }, TestContext.Current.CancellationToken); + var resolved = + await CreateService().ResolveDeployedAsync( + _collector, + new ResolveScrubberAllowlistArguments { AssemblerPath = "/repo/config/assembler.yml" }, + TestContext.Current.CancellationToken + ); resolved.Should().NotBeNull(); resolved.MatchesLocal.Should().BeFalse(); diff --git a/tests/Elastic.Changelog.Tests/AllowlistIdentity/ScrubberAllowlistIdentityTests.cs b/tests/Elastic.Changelog.Tests/AllowlistIdentity/ScrubberAllowlistIdentityTests.cs index eba6902f6a..03e238e1ab 100644 --- a/tests/Elastic.Changelog.Tests/AllowlistIdentity/ScrubberAllowlistIdentityTests.cs +++ b/tests/Elastic.Changelog.Tests/AllowlistIdentity/ScrubberAllowlistIdentityTests.cs @@ -18,7 +18,8 @@ private static string ValidJson( int schemaVersion = ScrubberAllowlistIdentity.CurrentSchemaVersion, string artifact = ScrubberAllowlistIdentity.ArtifactKind, string sha = ValidSha, - string commit = ValidCommit) => + string commit = ValidCommit + ) => $$""" { "schema_version": {{schemaVersion}}, diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendEndToEndTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendEndToEndTests.cs index 76c4965051..7e07aa8725 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendEndToEndTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendEndToEndTests.cs @@ -65,7 +65,9 @@ public async Task AmendLifecycle_FromCreationToVersionFilteredCdnConsumption() var parentPath = FileSystem.Path.Join(bundleDir, "elasticsearch-9.3.0.yaml"); // A resolved parent whose entry carries a file identity, so the retraction below can match it. // language=yaml - await FileSystem.File.WriteAllTextAsync(parentPath, $""" + await FileSystem.File.WriteAllTextAsync( + parentPath, + $""" products: - product: elasticsearch target: 9.3.0 @@ -80,16 +82,18 @@ await FileSystem.File.WriteAllTextAsync(parentPath, $""" title: Retracted fix prs: - "100" - """, ct); + """, + ct + ); // -- 1. bundle-amend materializes a self-contained amend ----------------------------- var amendService = new ChangelogBundleAmendService(LoggerFactory, FileSystem); - var amendResult = await amendService.AmendBundle(Collector, new AmendBundleArguments - { - BundlePath = parentPath, - AddFiles = [addedFile], - RemoveFiles = [retractedFile] - }, ct); + var amendResult = + await amendService.AmendBundle( + Collector, + new AmendBundleArguments { BundlePath = parentPath, AddFiles = [addedFile], RemoveFiles = [retractedFile] }, + ct + ); amendResult.Should().BeTrue(); Collector.Errors.Should().Be(0); @@ -108,9 +112,9 @@ await FileSystem.File.WriteAllTextAsync(parentPath, $""" var uploadService = new ChangelogUploadService(NullLoggerFactory.Instance, fileSystem: FileSystem, s3Client: s3.Client); var targets = uploadService.DiscoverBundleUploadTargets(uploadCollector, bundleDir); - targets.Select(t => t.S3Key).Should().BeEquivalentTo( - "bundle/elasticsearch/elasticsearch-9.3.0.yaml", - "bundle/elasticsearch/elasticsearch-9.3.0.amend-1.yaml"); + targets.Select(t => t.S3Key) + .Should() + .BeEquivalentTo("bundle/elasticsearch/elasticsearch-9.3.0.yaml", "bundle/elasticsearch/elasticsearch-9.3.0.amend-1.yaml"); uploadCollector.Errors.Should().Be(0); uploadCollector.Warnings.Should().Be(0); @@ -147,13 +151,15 @@ await FileSystem.File.WriteAllTextAsync(parentPath, $""" var fetchErrors = new List(); var fetchWarnings = new List(); using var fetcher = new CdnChangelogFetcher(NullLoggerFactory.Instance, FileSystem, handler); - var bundles = await fetcher.FetchAsync( - new Uri("https://cdn.example"), - "elasticsearch", - version: "9.3.0", - fetchErrors.Add, - fetchWarnings.Add, - ct); + var bundles = + await fetcher.FetchAsync( + new Uri("https://cdn.example"), + "elasticsearch", + version: "9.3.0", + fetchErrors.Add, + fetchWarnings.Add, + ct + ); fetchErrors.Should().BeEmpty(); fetchWarnings.Should().BeEmpty(); @@ -161,7 +167,8 @@ await FileSystem.File.WriteAllTextAsync(parentPath, $""" bundles[0].Version.Should().Be("9.3.0"); bundles[0].Entries.Select(e => e.Title).Should().BeEquivalentTo( ["Late addition"], - "the added entry is present and the file-identity retraction removed the original entry"); + "the added entry is present and the file-identity retraction removed the original entry" + ); } private static HttpResponseMessage Response(string body, string mediaType) => diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendMergerTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendMergerTests.cs index 8061ee47d9..90f5cf4e9b 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendMergerTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendMergerTests.cs @@ -13,17 +13,9 @@ public class BundleAmendMergerTests [Fact] public void MergeEntries_AppliesExclusionsBeforeAdditionsWithinAmend() { - var parent = new List - { - CreateFileEntry("keep.yaml", "aaa"), - CreateFileEntry("remove.yaml", "bbb") - }; + var parent = new List { CreateFileEntry("keep.yaml", "aaa"), CreateFileEntry("remove.yaml", "bbb") }; - var amend = new Bundle - { - ExcludeEntries = [CreateFileEntry("remove.yaml", "bbb")], - Entries = [CreateFileEntry("add.yaml", "ccc")] - }; + var amend = new Bundle { ExcludeEntries = [CreateFileEntry("remove.yaml", "bbb")], Entries = [CreateFileEntry("add.yaml", "ccc")] }; var merged = BundleAmendMerger.MergeEntries(parent, [amend]); @@ -38,14 +30,8 @@ public void MergeEntries_AppliesAmendsInOrder() { var parent = new List { CreateFileEntry("one.yaml", "1") }; - var amend1 = new Bundle - { - Entries = [CreateFileEntry("two.yaml", "2")] - }; - var amend2 = new Bundle - { - ExcludeEntries = [CreateFileEntry("one.yaml", "1")] - }; + var amend1 = new Bundle { Entries = [CreateFileEntry("two.yaml", "2")] }; + var amend2 = new Bundle { ExcludeEntries = [CreateFileEntry("one.yaml", "1")] }; var merged = BundleAmendMerger.MergeEntries(parent, [amend1, amend2]); @@ -65,15 +51,8 @@ public void GetParentBundlePath_AmendFile_StripsAmendSuffix(string amendPath, st [InlineData("9.3.0.yaml")] [InlineData("9.3.0.amend-.yaml")] [InlineData("9.3.0.amend-1.json")] - public void GetParentBundlePath_NonAmendFile_ReturnsNull(string path) => - BundleAmendMerger.GetParentBundlePath(path).Should().BeNull(); + public void GetParentBundlePath_NonAmendFile_ReturnsNull(string path) => BundleAmendMerger.GetParentBundlePath(path).Should().BeNull(); - private static BundledEntry CreateFileEntry(string name, string checksum) => new() - { - File = new BundledFile - { - Name = name, - Checksum = checksum - } - }; + private static BundledEntry CreateFileEntry(string name, string checksum) => + new() { File = new BundledFile { Name = name, Checksum = checksum } }; } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendTests.cs index 0faa715f48..9809b0f635 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendTests.cs @@ -52,12 +52,7 @@ private async Task CreateBundle(CancellationToken ct) await FileSystem.File.WriteAllTextAsync(changelogFile, changelog, ct); var bundlePath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); - var input = new BundleChangelogsArguments - { - Directory = _changelogDir, - All = true, - Output = bundlePath - }; + var input = new BundleChangelogsArguments { Directory = _changelogDir, All = true, Output = bundlePath }; var result = await BundleService.BundleChangelogs(Collector, input, ct); result.Should().BeTrue("bundle creation should succeed"); @@ -106,11 +101,7 @@ public async Task AmendBundle_AddFile_WritesResolvedEntryWithProvenance() // Reset collector for the amend operation var amendCollector = new TestDiagnosticsCollector(Output); - var input = new AmendBundleArguments - { - BundlePath = bundlePath, - AddFiles = [newFile] - }; + var input = new AmendBundleArguments { BundlePath = bundlePath, AddFiles = [newFile] }; var result = await Service.AmendBundle(amendCollector, input, ct); @@ -140,11 +131,7 @@ public async Task AmendBundle_RemoveFromParent_CreatesExcludeEntries() var changelogFile = FileSystem.Path.Join(_changelogDir, "1755268130-existing.yaml"); var amendCollector = new TestDiagnosticsCollector(Output); - var input = new AmendBundleArguments - { - BundlePath = bundlePath, - RemoveFiles = [changelogFile] - }; + var input = new AmendBundleArguments { BundlePath = bundlePath, RemoveFiles = [changelogFile] }; var result = await Service.AmendBundle(amendCollector, input, ct); @@ -169,19 +156,11 @@ public async Task AmendBundle_RemoveAfterAdd_ExcludesAmendedEntry() var newFile = await CreateNewChangelogFile(ct); var addCollector = new TestDiagnosticsCollector(Output); - var addInput = new AmendBundleArguments - { - BundlePath = bundlePath, - AddFiles = [newFile] - }; + var addInput = new AmendBundleArguments { BundlePath = bundlePath, AddFiles = [newFile] }; (await Service.AmendBundle(addCollector, addInput, ct)).Should().BeTrue(); var removeCollector = new TestDiagnosticsCollector(Output); - var removeInput = new AmendBundleArguments - { - BundlePath = bundlePath, - RemoveFiles = [newFile] - }; + var removeInput = new AmendBundleArguments { BundlePath = bundlePath, RemoveFiles = [newFile] }; var result = await Service.AmendBundle(removeCollector, removeInput, ct); @@ -214,20 +193,16 @@ await FileSystem.File.WriteAllTextAsync( prs: - "100" """, - ct); + ct + ); var amendCollector = new TestDiagnosticsCollector(Output); - var input = new AmendBundleArguments - { - BundlePath = bundlePath, - RemoveFiles = [changelogFile] - }; + var input = new AmendBundleArguments { BundlePath = bundlePath, RemoveFiles = [changelogFile] }; var result = await Service.AmendBundle(amendCollector, input, ct); result.Should().BeFalse(); - amendCollector.Diagnostics.Should().ContainSingle(d => - d.Message.Contains("different checksum")); + amendCollector.Diagnostics.Should().ContainSingle(d => d.Message.Contains("different checksum")); } [Fact] @@ -239,12 +214,7 @@ public async Task AmendBundle_RemoveAndAdd_InSingleAmendFile() var addFile = await CreateNewChangelogFile(ct); var amendCollector = new TestDiagnosticsCollector(Output); - var input = new AmendBundleArguments - { - BundlePath = bundlePath, - RemoveFiles = [removeFile], - AddFiles = [addFile] - }; + var input = new AmendBundleArguments { BundlePath = bundlePath, RemoveFiles = [removeFile], AddFiles = [addFile] }; var result = await Service.AmendBundle(amendCollector, input, ct); @@ -287,7 +257,9 @@ private async Task CreateBundleWithFullProducts(CancellationToken ct) FileSystem.Directory.CreateDirectory(bundleDir); var bundlePath = FileSystem.Path.Join(bundleDir, "elasticsearch-9.3.0.yaml"); // language=yaml - await FileSystem.File.WriteAllTextAsync(bundlePath, $""" + await FileSystem.File.WriteAllTextAsync( + bundlePath, + $""" products: - product: elasticsearch target: 9.3.0 @@ -302,7 +274,9 @@ await FileSystem.File.WriteAllTextAsync(bundlePath, $""" title: Existing feature prs: - "100" - """, ct); + """, + ct + ); return bundlePath; } @@ -315,11 +289,7 @@ public async Task AmendBundle_Add_CopiesParentProductsIntoAmend() var newFile = await CreateNewChangelogFile(ct); var amendCollector = new TestDiagnosticsCollector(Output); - var input = new AmendBundleArguments - { - BundlePath = bundlePath, - AddFiles = [newFile] - }; + var input = new AmendBundleArguments { BundlePath = bundlePath, AddFiles = [newFile] }; var result = await Service.AmendBundle(amendCollector, input, ct); @@ -334,14 +304,16 @@ public async Task AmendBundle_Add_CopiesParentProductsIntoAmend() // The amend must be self-contained: complete parent products, including target, repo, and owner. amend.Products.Should().ContainSingle(); - amend.Products[0].Should().BeEquivalentTo(new BundledProduct - { - ProductId = "elasticsearch", - Target = "9.3.0", - Lifecycle = Lifecycle.Ga, - Repo = "elasticsearch", - Owner = "elastic" - }); + amend.Products[0] + .Should() + .BeEquivalentTo(new BundledProduct + { + ProductId = "elasticsearch", + Target = "9.3.0", + Lifecycle = Lifecycle.Ga, + Repo = "elasticsearch", + Owner = "elastic" + }); } [Fact] @@ -352,11 +324,7 @@ public async Task AmendBundle_Remove_CopiesParentProductsIntoAmend() var changelogFile = FileSystem.Path.Join(_changelogDir, "1755268130-existing.yaml"); var amendCollector = new TestDiagnosticsCollector(Output); - var input = new AmendBundleArguments - { - BundlePath = bundlePath, - RemoveFiles = [changelogFile] - }; + var input = new AmendBundleArguments { BundlePath = bundlePath, RemoveFiles = [changelogFile] }; var result = await Service.AmendBundle(amendCollector, input, ct); @@ -384,21 +352,18 @@ public async Task AmendBundle_CorruptExistingAmend_FailsWithoutWritingNewAmend() await FileSystem.File.WriteAllTextAsync( FileSystem.Path.ChangeExtension(bundlePath, ".amend-1.yaml"), "exclude-entries:\n - file: [invalid yaml", - ct); + ct + ); var amendCollector = new TestDiagnosticsCollector(Output); - var input = new AmendBundleArguments - { - BundlePath = bundlePath, - RemoveFiles = [changelogFile] - }; + var input = new AmendBundleArguments { BundlePath = bundlePath, RemoveFiles = [changelogFile] }; var result = await Service.AmendBundle(amendCollector, input, ct); result.Should().BeFalse(); - amendCollector.Diagnostics.Should().ContainSingle(d => - d.Severity == Severity.Error && - d.Message.Contains("Failed to deserialize amend file")); + amendCollector.Diagnostics + .Should() + .ContainSingle(d => d.Severity == Severity.Error && d.Message.Contains("Failed to deserialize amend file")); var amendFiles = ChangelogBundleAmendService.DiscoverAmendFiles(FileSystem, bundlePath); amendFiles.Should().HaveCount(1, "corrupt amend should not produce a second amend file"); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs index 63b770eae0..fdfb4108e3 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs @@ -20,7 +20,8 @@ namespace Elastic.Changelog.Tests.Changelogs; public class BundleCdnSourcingTests(ITestOutputHelper output) : ChangelogTestBase(output) { // language=yaml - private const string EntryAlpha = """ + private const string EntryAlpha = + """ title: Alpha type: feature products: @@ -32,7 +33,8 @@ public class BundleCdnSourcingTests(ITestOutputHelper output) : ChangelogTestBas """; // language=yaml - private const string EntryBravo = """ + private const string EntryBravo = + """ title: Bravo type: feature products: @@ -47,17 +49,18 @@ public class BundleCdnSourcingTests(ITestOutputHelper output) : ChangelogTestBas private const string RegistryJson = """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "1-alpha.yaml" }, { "file": "2-bravo.yaml" } ] }"""; - private static StubHandler RegistryHandler() => new(req => - { - var path = req.RequestUri!.AbsolutePath; - if (path.EndsWith("/registry.json", StringComparison.Ordinal)) - return Json(RegistryJson); - if (path.EndsWith("1-alpha.yaml", StringComparison.Ordinal)) - return Yaml(EntryAlpha); - if (path.EndsWith("2-bravo.yaml", StringComparison.Ordinal)) - return Yaml(EntryBravo); - return new HttpResponseMessage(HttpStatusCode.NotFound); - }); + private static StubHandler RegistryHandler() => + new(req => + { + var path = req.RequestUri!.AbsolutePath; + if (path.EndsWith("/registry.json", StringComparison.Ordinal)) + return Json(RegistryJson); + if (path.EndsWith("1-alpha.yaml", StringComparison.Ordinal)) + return Yaml(EntryAlpha); + if (path.EndsWith("2-bravo.yaml", StringComparison.Ordinal)) + return Yaml(EntryBravo); + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); // No-op sleeper so any entry retry stays instant in tests. private static CdnChangelogEntryFetcher Fetcher(ITestOutputHelper output, StubHandler handler) => @@ -65,8 +68,7 @@ private static CdnChangelogEntryFetcher Fetcher(ITestOutputHelper output, StubHa private CdnChangelogEntryFetcher Fetcher() => Fetcher(Output, RegistryHandler()); - private string OutputPath() => - FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + private string OutputPath() => FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); [Fact] public async Task OptionMode_RepoResolvable_SourcesAllEntriesFromRepoPoolOnCdn() @@ -86,7 +88,9 @@ public async Task OptionMode_RepoResolvable_SourcesAllEntriesFromRepoPoolOnCdn() var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); - result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + result.Should().BeTrue( + $"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}" + ); Collector.Errors.Should().Be(0); // Entries are sourced from the authoring pool, with org/branch defaulting: changelog/{org}/{repo}/{branch}/... @@ -117,7 +121,9 @@ public async Task OptionMode_OwnerAndBranchOverride_SourcesFromThatPoolOnCdn() var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); - result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + result.Should().BeTrue( + $"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}" + ); Collector.Errors.Should().Be(0); handler.RequestedPaths.Should().Contain("/changelog/acme-corp/elasticsearch/8.x/registry.json"); } @@ -140,7 +146,9 @@ public async Task OptionMode_OwnerFromCombinedRepo_SourcesFromThatPool() var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); - result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + result.Should().BeTrue( + $"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}" + ); Collector.Errors.Should().Be(0); handler.RequestedPaths.Should().Contain("/changelog/acme-corp/widget/main/registry.json"); } @@ -154,7 +162,10 @@ public async Task OptionMode_NoResolvableRepo_FallsBackToLocal() var localDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog"); FileSystem.Directory.CreateDirectory(localDir); await FileSystem.File.WriteAllTextAsync( - FileSystem.Path.Join(localDir, "1-local.yaml"), EntryAlpha, TestContext.Current.CancellationToken); + FileSystem.Path.Join(localDir, "1-local.yaml"), + EntryAlpha, + TestContext.Current.CancellationToken + ); var configContent = $""" @@ -173,7 +184,9 @@ await FileSystem.File.WriteAllTextAsync( var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); - result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + result.Should().BeTrue( + $"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}" + ); handler.RequestedPaths.Should().BeEmpty("local fallback must not reach the CDN"); var bundle = await FileSystem.File.ReadAllTextAsync(output, TestContext.Current.CancellationToken); @@ -188,7 +201,10 @@ public async Task OptionMode_UseLocalChangelogs_ForcesLocalEvenWithResolvableRep var localDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog"); FileSystem.Directory.CreateDirectory(localDir); await FileSystem.File.WriteAllTextAsync( - FileSystem.Path.Join(localDir, "1-local.yaml"), EntryAlpha, TestContext.Current.CancellationToken); + FileSystem.Path.Join(localDir, "1-local.yaml"), + EntryAlpha, + TestContext.Current.CancellationToken + ); var configContent = $""" @@ -214,7 +230,9 @@ await FileSystem.File.WriteAllTextAsync( var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); - result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + result.Should().BeTrue( + $"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}" + ); handler.RequestedPaths.Should().BeEmpty("use_local_changelogs must not reach the CDN"); var bundle = await FileSystem.File.ReadAllTextAsync(output, TestContext.Current.CancellationToken); @@ -224,8 +242,11 @@ await FileSystem.File.WriteAllTextAsync( [Fact] public async Task RegistryFailure_FailsBundle() { - var fetcher = new CdnChangelogEntryFetcher(new TestLoggerFactory(Output), - new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)), sleep: (_, _) => Task.CompletedTask); + var fetcher = new CdnChangelogEntryFetcher( + new TestLoggerFactory(Output), + new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)), + sleep: (_, _) => Task.CompletedTask + ); var service = new ChangelogBundlingService(LoggerFactory, FileSystem, null, null, fetcher); var input = new BundleChangelogsArguments @@ -255,7 +276,12 @@ public async Task EntryMissingAfterRetries_FailsBundle() return Yaml(EntryAlpha); return new HttpResponseMessage(HttpStatusCode.NotFound); // 2-bravo.yaml never propagates }); - var fetcher = new CdnChangelogEntryFetcher(new TestLoggerFactory(Output), handler, maxAttempts: 2, sleep: (_, _) => Task.CompletedTask); + var fetcher = new CdnChangelogEntryFetcher( + new TestLoggerFactory(Output), + handler, + maxAttempts: 2, + sleep: (_, _) => Task.CompletedTask + ); var service = new ChangelogBundlingService(LoggerFactory, FileSystem, null, null, fetcher); var input = new BundleChangelogsArguments @@ -281,8 +307,7 @@ public async Task ProfileGitHubRelease_ScopesByOutputProductsAndFiltersByRelease FileSystem.Directory.CreateDirectory(outputDir); // language=yaml - var configContent = - """ + var configContent = """ bundle: output_directory: PLACEHOLDER owner: elastic @@ -292,27 +317,28 @@ public async Task ProfileGitHubRelease_ScopesByOutputProductsAndFiltersByRelease repo: elasticsearch output: "elasticsearch-{version}.yaml" output_products: "elasticsearch {version} {lifecycle}" - """.Replace("PLACEHOLDER", outputDir); + """.Replace( + "PLACEHOLDER", + outputDir + ); var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); var releaseBody = "* Alpha by @user in https://github.com/elastic/elasticsearch/pull/100\n"; - A.CallTo(() => releaseService.FetchReleaseAsync("elastic", "elasticsearch", "9.3.0", TestContext.Current.CancellationToken)) - .Returns(new GitHubReleaseInfo { TagName = "v9.3.0", Name = "9.3.0", Body = releaseBody }); + A.CallTo( + () => releaseService.FetchReleaseAsync("elastic", "elasticsearch", "9.3.0", TestContext.Current.CancellationToken) + ).Returns(new GitHubReleaseInfo { TagName = "v9.3.0", Name = "9.3.0", Body = releaseBody }); var service = new ChangelogBundlingService(LoggerFactory, FileSystem, ConfigurationContext, releaseService, Fetcher()); - var input = new BundleChangelogsArguments - { - Profile = "es-release", - ProfileArgument = "9.3.0", - Config = configPath - }; + var input = new BundleChangelogsArguments { Profile = "es-release", ProfileArgument = "9.3.0", Config = configPath }; var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); - result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + result.Should().BeTrue( + $"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}" + ); Collector.Errors.Should().Be(0); var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs index 8f195f8c52..746b79e2f2 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs @@ -350,12 +350,20 @@ public async Task BundleChangelogs_WithPrsFilterAndUnmatchedPrs_EmitsWarnings() result.Should().BeTrue(); Collector.Errors.Should().Be(0); Collector.Warnings.Should().Be(2); // Two unmatched PRs - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Warning && - d.Message.Contains("No changelog file found for PR: https://github.com/elastic/elasticsearch/pull/200")); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Warning && - d.Message.Contains("No changelog file found for PR: https://github.com/elastic/elasticsearch/pull/300")); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Severity == Severity.Warning && + d.Message.Contains("No changelog file found for PR: https://github.com/elastic/elasticsearch/pull/200") + ); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Severity == Severity.Warning && + d.Message.Contains("No changelog file found for PR: https://github.com/elastic/elasticsearch/pull/300") + ); } [Fact] @@ -517,7 +525,9 @@ public async Task BundleChangelogs_WithNoMatchingFiles_ReturnsError() // Assert result.Should().BeFalse(); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => d.Message.Contains("No YAML files found") || d.Message.Contains("No changelog entries matched")); + Collector.Diagnostics + .Should() + .Contain(d => d.Message.Contains("No YAML files found") || d.Message.Contains("No changelog entries matched")); } [Fact] @@ -1293,10 +1303,7 @@ public async Task BundleChangelogs_WithInputProductsWildcardLifecycle_ExtractsAc var input = new BundleChangelogsArguments { Directory = _changelogDir, - InputProducts = - [ - new ProductArgument { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "*" } - ], + InputProducts = [new ProductArgument { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "*" }], Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; @@ -1382,11 +1389,13 @@ public async Task BundleChangelogs_WithMultipleTargets_WarningIncludesLifecycle( Collector.Errors.Should().Be(0); Collector.Warnings.Should().BeGreaterThan(0); // Verify warning message includes lifecycle values - Collector.Diagnostics.Should().Contain(d => - d.Message.Contains("Product 'elasticsearch' has multiple targets in bundle") && - d.Message.Contains("9.2.0") && - d.Message.Contains("9.2.0 beta") && - d.Message.Contains("9.2.0 ga")); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Message.Contains("Product 'elasticsearch' has multiple targets in bundle") && d.Message.Contains("9.2.0") && + d.Message.Contains("9.2.0 beta") && d.Message.Contains("9.2.0 ga") + ); } [Fact] @@ -1470,12 +1479,7 @@ public async Task BundleChangelogs_PreservesSpecialCharactersInUtf8() await FileSystem.File.WriteAllTextAsync(file1, changelog1, Encoding.UTF8, TestContext.Current.CancellationToken); var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); - var input = new BundleChangelogsArguments - { - Directory = _changelogDir, - All = true, - Output = outputPath - }; + var input = new BundleChangelogsArguments { Directory = _changelogDir, All = true, Output = outputPath }; // Act var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -1552,12 +1556,7 @@ public async Task BundleChangelogs_WithDirectoryOutputPath_CreatesDefaultFilenam var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); var outputPath = FileSystem.Path.Join(outputDir, "changelog-bundle.yaml"); - var input = new BundleChangelogsArguments - { - Directory = _changelogDir, - All = true, - Output = outputPath - }; + var input = new BundleChangelogsArguments { Directory = _changelogDir, All = true, Output = outputPath }; // Act var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -1646,8 +1645,7 @@ public async Task BundleChangelogs_WithResolveAndMissingProducts_ReturnsError() // Arrange // language=yaml - var changelog1 = - """ + var changelog1 = """ title: Test feature type: feature """; @@ -1685,8 +1683,7 @@ public async Task BundleChangelogs_WithMultipleInvalidEntries_ReportsAllInOnePas """; // language=yaml - var changelog2 = - """ + var changelog2 = """ title: Second feature type: feature """; @@ -1852,7 +1849,11 @@ public async Task BundleChangelogs_WithHideFeaturesFromFile_IncludesHideFeatures // Create feature IDs file var featureIdsFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "feature-ids.txt"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(featureIdsFile)!); - await FileSystem.File.WriteAllTextAsync(featureIdsFile, "feature:from-file\nfeature:another", TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync( + featureIdsFile, + "feature:from-file\nfeature:another", + TestContext.Current.CancellationToken + ); var input = new BundleChangelogsArguments { @@ -1901,6 +1902,7 @@ public async Task BundleChangelogs_WithRepoOption_IncludesRepoInBundleProducts() Directory = _changelogDir, All = true, Repo = "cloud", // Set repo to "cloud" - different from product ID "cloud-serverless" + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; @@ -1964,8 +1966,7 @@ public async Task BundleChangelogs_WithBundleLevelRepoConfig_UsesConfigRepoWhenO // Arrange - bundle.repo in config is used when --repo is not provided on the CLI // language=yaml - var configContent = - """ + var configContent = """ bundle: repo: cloud owner: elastic @@ -2007,7 +2008,9 @@ public async Task BundleChangelogs_WithBundleLevelRepoConfig_UsesConfigRepoWhenO var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); // Assert - result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + result.Should().BeTrue( + $"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}" + ); Collector.Errors.Should().Be(0); var bundleContent = await FileSystem.File.ReadAllTextAsync(outputPath, TestContext.Current.CancellationToken); @@ -2020,8 +2023,7 @@ public async Task BundleChangelogs_WithRepoOptionAndBundleLevelConfig_CliOptionT // Arrange - explicit --repo overrides bundle.repo in config // language=yaml - var configContent = - """ + var configContent = """ bundle: repo: wrong-repo """; @@ -2055,14 +2057,17 @@ public async Task BundleChangelogs_WithRepoOptionAndBundleLevelConfig_CliOptionT All = true, Config = configPath, Output = outputPath, - Repo = "cloud" // explicit CLI --repo should win over bundle.repo: wrong-repo + Repo = + "cloud" // explicit CLI --repo should win over bundle.repo: wrong-repo }; // Act var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); // Assert - result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + result.Should().BeTrue( + $"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}" + ); Collector.Errors.Should().Be(0); var bundleContent = await FileSystem.File.ReadAllTextAsync(outputPath, TestContext.Current.CancellationToken); @@ -2153,19 +2158,15 @@ public async Task BundleChangelogs_WithConfigOutputDirectory_WhenOutputNotSpecif var file1 = FileSystem.Path.Join(_changelogDir, "1755268130-feature.yaml"); await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); - var input = new BundleChangelogsArguments - { - Directory = _changelogDir, - Config = configPath, - Output = null, - All = true - }; + var input = new BundleChangelogsArguments { Directory = _changelogDir, Config = configPath, Output = null, All = true }; // Act var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); // Assert - result.Should().BeTrue($"Expected bundling to succeed. Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + result.Should().BeTrue( + $"Expected bundling to succeed. Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}" + ); Collector.Errors.Should().Be(0); var expectedOutputPath = FileSystem.Path.Join(outputDir, "changelog-bundle.yaml"); @@ -2212,19 +2213,15 @@ public async Task BundleChangelogs_WithConfigDirectory_WhenDirectoryNotSpecified var file1 = FileSystem.Path.Join(_changelogDir, "1755268130-feature.yaml"); await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); - var input = new BundleChangelogsArguments - { - Directory = null, - Config = configPath, - Output = null, - All = true - }; + var input = new BundleChangelogsArguments { Directory = null, Config = configPath, Output = null, All = true }; // Act - Directory not specified, so ApplyConfigDefaults uses config.Bundle.Directory var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); // Assert - result.Should().BeTrue($"Expected bundling to succeed. Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + result.Should().BeTrue( + $"Expected bundling to succeed. Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}" + ); Collector.Errors.Should().Be(0); var expectedOutputPath = FileSystem.Path.Join(outputDir, "changelog-bundle.yaml"); @@ -2285,7 +2282,9 @@ public async Task BundleChangelogs_WithExplicitDirectory_OverridesConfigDirector var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); // Assert - used _changelogDir (CLI), not configDir (config) - result.Should().BeTrue($"Expected bundling to succeed. Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + result.Should().BeTrue( + $"Expected bundling to succeed. Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}" + ); Collector.Errors.Should().Be(0); var bundleContent = await FileSystem.File.ReadAllTextAsync(input.Output, TestContext.Current.CancellationToken); @@ -2348,7 +2347,9 @@ public async Task BundleChangelogs_WithProfileHideFeatures_IncludesHideFeaturesI var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); // Assert - result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + result.Should().BeTrue( + $"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}" + ); Collector.Errors.Should().Be(0); // Find the output file @@ -2417,7 +2418,9 @@ public async Task BundleChangelogs_WithProfile_OnlyProfileHideFeaturesAreUsed() var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); // Assert - result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + result.Should().BeTrue( + $"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}" + ); Collector.Errors.Should().Be(0); // Find the output file @@ -2484,7 +2487,9 @@ public async Task BundleChangelogs_WithProfileMultipleHideFeatures_AllProfileFea var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); // Assert - result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + result.Should().BeTrue( + $"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}" + ); Collector.Errors.Should().Be(0); var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); @@ -2553,8 +2558,7 @@ public async Task BundleChangelogs_WithComments_ProducesNormalizedChecksum() // Both versions of the content should produce the same checksum (comments are normalized away) var checksumFromCommented = ComputeSha1(changelogWithComments); var checksumFromUncommented = ComputeSha1(changelogWithoutComments); - checksumFromCommented.Should().Be(checksumFromUncommented, - "checksums should be identical regardless of comments"); + checksumFromCommented.Should().Be(checksumFromUncommented, "checksums should be identical regardless of comments"); } [Fact] @@ -2596,10 +2600,12 @@ public async Task BundleChangelogs_WithAndWithoutComments_ProduceSameChecksum() await FileSystem.File.WriteAllTextAsync(file1, changelogWithComments, TestContext.Current.CancellationToken); var output1 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle1.yaml"); - var result1 = await Service.BundleChangelogs(Collector, new BundleChangelogsArguments - { - Directory = dir1, All = true, Output = output1 - }, TestContext.Current.CancellationToken); + var result1 = + await Service.BundleChangelogs( + Collector, + new BundleChangelogsArguments { Directory = dir1, All = true, Output = output1 }, + TestContext.Current.CancellationToken + ); // Bundle without comments var dir2 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); @@ -2608,10 +2614,12 @@ public async Task BundleChangelogs_WithAndWithoutComments_ProduceSameChecksum() await FileSystem.File.WriteAllTextAsync(file2, changelogWithoutComments, TestContext.Current.CancellationToken); var output2 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle2.yaml"); - var result2 = await Service.BundleChangelogs(Collector, new BundleChangelogsArguments - { - Directory = dir2, All = true, Output = output2 - }, TestContext.Current.CancellationToken); + var result2 = + await Service.BundleChangelogs( + Collector, + new BundleChangelogsArguments { Directory = dir2, All = true, Output = output2 }, + TestContext.Current.CancellationToken + ); // Assert result1.Should().BeTrue(); @@ -2624,8 +2632,7 @@ public async Task BundleChangelogs_WithAndWithoutComments_ProduceSameChecksum() var checksum1 = ExtractChecksum(bundle1); var checksum2 = ExtractChecksum(bundle2); - checksum1.Should().Be(checksum2, - "bundles from files with and without comments should have the same normalized checksum"); + checksum1.Should().Be(checksum2, "bundles from files with and without comments should have the same normalized checksum"); } [Fact] @@ -2660,24 +2667,34 @@ public async Task BundleChangelogs_WithDifferentData_ProducesDifferentChecksum() // Bundle first file var dir1 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(dir1); - await FileSystem.File.WriteAllTextAsync(FileSystem.Path.Join(dir1, "1755268130-a.yaml"), changelog1, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync( + FileSystem.Path.Join(dir1, "1755268130-a.yaml"), + changelog1, + TestContext.Current.CancellationToken + ); var output1 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle1.yaml"); - await Service.BundleChangelogs(Collector, new BundleChangelogsArguments - { - Directory = dir1, All = true, Output = output1 - }, TestContext.Current.CancellationToken); + await Service.BundleChangelogs( + Collector, + new BundleChangelogsArguments { Directory = dir1, All = true, Output = output1 }, + TestContext.Current.CancellationToken + ); // Bundle second file var dir2 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(dir2); - await FileSystem.File.WriteAllTextAsync(FileSystem.Path.Join(dir2, "1755268130-b.yaml"), changelog2, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync( + FileSystem.Path.Join(dir2, "1755268130-b.yaml"), + changelog2, + TestContext.Current.CancellationToken + ); var output2 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle2.yaml"); - await Service.BundleChangelogs(Collector, new BundleChangelogsArguments - { - Directory = dir2, All = true, Output = output2 - }, TestContext.Current.CancellationToken); + await Service.BundleChangelogs( + Collector, + new BundleChangelogsArguments { Directory = dir2, All = true, Output = output2 }, + TestContext.Current.CancellationToken + ); // Assert var bundle1 = await FileSystem.File.ReadAllTextAsync(output1, TestContext.Current.CancellationToken); @@ -2686,8 +2703,7 @@ public async Task BundleChangelogs_WithDifferentData_ProducesDifferentChecksum() var checksum1 = ExtractChecksum(bundle1); var checksum2 = ExtractChecksum(bundle2); - checksum1.Should().NotBe(checksum2, - "files with different data should produce different checksums"); + checksum1.Should().NotBe(checksum2, "files with different data should produce different checksums"); } [Fact] @@ -2732,11 +2748,7 @@ public async Task AmendBundle_WithComments_ProducesNormalizedChecksum() var amendService = new ChangelogBundleAmendService(LoggerFactory, FileSystem); - var amendInput = new AmendBundleArguments - { - BundlePath = bundleFile, - AddFiles = [changelogFile] - }; + var amendInput = new AmendBundleArguments { BundlePath = bundleFile, AddFiles = [changelogFile] }; // Act var result = await amendService.AmendBundle(Collector, amendInput, TestContext.Current.CancellationToken); @@ -2814,7 +2826,9 @@ public async Task BundleChangelogs_WithProfile_OutputProducts_OverridesProductsA var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); // Assert - result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + result.Should().BeTrue( + $"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}" + ); Collector.Errors.Should().Be(0); var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); @@ -2874,9 +2888,13 @@ public async Task BundleChangelogs_WithProfile_MalformedOutputProducts_EmitsErro result.Should().BeFalse(); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Message.Contains("at most three space-separated fields", StringComparison.Ordinal) && - d.Message.Contains("elasticsearch 9.2.0 ga extra-token", StringComparison.Ordinal)); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Message.Contains("at most three space-separated fields", StringComparison.Ordinal) && + d.Message.Contains("elasticsearch 9.2.0 ga extra-token", StringComparison.Ordinal) + ); } [Fact] @@ -2926,10 +2944,14 @@ public async Task BundleChangelogs_WithProfile_MalformedProductsPattern_EmitsErr result.Should().BeFalse(); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Message.Contains("Profile 'es-release':", StringComparison.Ordinal) && - d.Message.Contains("at most three space-separated fields", StringComparison.Ordinal) && - d.Message.Contains("elasticsearch 9.2.0 ga extra bad", StringComparison.Ordinal)); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Message.Contains("Profile 'es-release':", StringComparison.Ordinal) && + d.Message.Contains("at most three space-separated fields", StringComparison.Ordinal) && + d.Message.Contains("elasticsearch 9.2.0 ga extra bad", StringComparison.Ordinal) + ); } [Fact] @@ -2967,7 +2989,11 @@ public async Task BundleChangelogs_WithProfile_DateVersionAndLifecyclePlaceholde var prListPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "prs.txt"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(prListPath)!); - await FileSystem.File.WriteAllTextAsync(prListPath, "https://github.com/elastic/kibana/pull/100\n", TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync( + prListPath, + "https://github.com/elastic/kibana/pull/100\n", + TestContext.Current.CancellationToken + ); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); @@ -2984,7 +3010,9 @@ public async Task BundleChangelogs_WithProfile_DateVersionAndLifecyclePlaceholde var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); - result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + result.Should().BeTrue( + $"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}" + ); Collector.Errors.Should().Be(0); var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); @@ -3052,7 +3080,9 @@ public async Task BundleChangelogs_WithProfile_RepoAndOwner_WritesValuesToProduc var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); // Assert - result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + result.Should().BeTrue( + $"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}" + ); Collector.Errors.Should().Be(0); var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); @@ -3116,7 +3146,9 @@ public async Task BundleChangelogs_WithProfile_BundleLevelRepo_AppliesWhenProfil var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); // Assert - result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + result.Should().BeTrue( + $"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}" + ); Collector.Errors.Should().Be(0); var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); @@ -3179,7 +3211,9 @@ public async Task BundleChangelogs_WithProfile_ProfileRepoOverridesBundleRepo() var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); // Assert - result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + result.Should().BeTrue( + $"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}" + ); Collector.Errors.Should().Be(0); var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); @@ -3242,7 +3276,9 @@ public async Task BundleChangelogs_WithProfile_NoRepoOwner_PreservesExistingFall var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); // Assert — succeeds without error; no repo field written to products - result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + result.Should().BeTrue( + $"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}" + ); Collector.Errors.Should().Be(0); var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); @@ -3258,10 +3294,7 @@ public async Task BundleChangelogs_WithProfileMode_MissingConfig_ReturnsErrorWit { // Arrange - no config file exists at ./changelog.yml or ./docs/changelog.yml. // Use a fresh MockFileSystem with a known CWD so discovery returns no results. - var cwdFs = new System.IO.Abstractions.TestingHelpers.MockFileSystem( - null, - currentDirectory: "/empty-project" - ); + var cwdFs = new System.IO.Abstractions.TestingHelpers.MockFileSystem(null, currentDirectory: "/empty-project"); cwdFs.Directory.CreateDirectory("/empty-project"); var service = new ChangelogBundlingService(LoggerFactory, ChangelogFileSystem.FromWorkingDirectory(cwdFs), ConfigurationContext); @@ -3277,11 +3310,12 @@ public async Task BundleChangelogs_WithProfileMode_MissingConfig_ReturnsErrorWit // Assert result.Should().BeFalse("Should fail when no config file is found"); - Collector.Diagnostics.Should().ContainSingle(d => - d.Severity == Severity.Error && - (d.Message.Contains("changelog.yml") || d.Message.Contains("changelog init")), - "Error message should mention changelog.yml or advise running changelog init" - ); + Collector.Diagnostics + .Should() + .ContainSingle( + d => d.Severity == Severity.Error && (d.Message.Contains("changelog.yml") || d.Message.Contains("changelog init")), + "Error message should mention changelog.yml or advise running changelog init" + ); } [Fact] @@ -3289,10 +3323,7 @@ public async Task BundleChangelogs_WithProfileMode_ConfigAtCurrentDir_LoadsSucce { // Arrange - changelog.yml is at ./changelog.yml (in the current working directory) var root = Paths.WorkingDirectoryRoot.FullName; - var cwdFs = new System.IO.Abstractions.TestingHelpers.MockFileSystem( - null, - currentDirectory: root - ); + var cwdFs = new System.IO.Abstractions.TestingHelpers.MockFileSystem(null, currentDirectory: root); cwdFs.Directory.CreateDirectory(root); cwdFs.Directory.CreateDirectory(Path.Join(root, "changelogs")); cwdFs.Directory.CreateDirectory(Path.Join(root, "output")); @@ -3322,7 +3353,11 @@ public async Task BundleChangelogs_WithProfileMode_ConfigAtCurrentDir_LoadsSucce prs: - https://github.com/elastic/elasticsearch/pull/100 """; - await cwdFs.File.WriteAllTextAsync(Path.Join(root, "changelogs/1755268130-feature.yaml"), changelogContent, TestContext.Current.CancellationToken); + await cwdFs.File.WriteAllTextAsync( + Path.Join(root, "changelogs/1755268130-feature.yaml"), + changelogContent, + TestContext.Current.CancellationToken + ); var service = new ChangelogBundlingService(LoggerFactory, ChangelogFileSystem.FromWorkingDirectory(cwdFs), ConfigurationContext); @@ -3338,7 +3373,9 @@ public async Task BundleChangelogs_WithProfileMode_ConfigAtCurrentDir_LoadsSucce var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); // Assert - result.Should().BeTrue($"Expected bundling to succeed. Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + result.Should().BeTrue( + $"Expected bundling to succeed. Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}" + ); Collector.Errors.Should().Be(0); cwdFs.Directory.GetFiles(Path.Join(root, "output"), "*.yaml").Should().NotBeEmpty("Expected output file to be created"); } @@ -3348,10 +3385,7 @@ public async Task BundleChangelogs_WithProfileMode_ConfigAtDocsSubdir_LoadsSucce { // Arrange - changelog.yml is at ./docs/changelog.yml (the second discovery candidate) var root = Paths.WorkingDirectoryRoot.FullName; - var cwdFs = new System.IO.Abstractions.TestingHelpers.MockFileSystem( - null, - currentDirectory: root - ); + var cwdFs = new System.IO.Abstractions.TestingHelpers.MockFileSystem(null, currentDirectory: root); cwdFs.Directory.CreateDirectory(root); cwdFs.Directory.CreateDirectory(Path.Join(root, "docs")); cwdFs.Directory.CreateDirectory(Path.Join(root, "changelogs")); @@ -3383,7 +3417,11 @@ public async Task BundleChangelogs_WithProfileMode_ConfigAtDocsSubdir_LoadsSucce prs: - https://github.com/elastic/elasticsearch/pull/100 """; - await cwdFs.File.WriteAllTextAsync(Path.Join(root, "changelogs/1755268130-feature.yaml"), changelogContent, TestContext.Current.CancellationToken); + await cwdFs.File.WriteAllTextAsync( + Path.Join(root, "changelogs/1755268130-feature.yaml"), + changelogContent, + TestContext.Current.CancellationToken + ); var service = new ChangelogBundlingService(LoggerFactory, ChangelogFileSystem.FromWorkingDirectory(cwdFs), ConfigurationContext); @@ -3399,7 +3437,9 @@ public async Task BundleChangelogs_WithProfileMode_ConfigAtDocsSubdir_LoadsSucce var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); // Assert - result.Should().BeTrue($"Expected bundling to succeed. Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + result.Should().BeTrue( + $"Expected bundling to succeed. Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}" + ); Collector.Errors.Should().Be(0); cwdFs.Directory.GetFiles(Path.Join(root, "output"), "*.yaml").Should().NotBeEmpty("Expected output file to be created"); } @@ -3410,7 +3450,8 @@ public async Task BundleChangelogs_WithProfileMode_ConfigAtDocsSubdir_LoadsSucce public async Task BundleChangelogs_WithProfile_UrlListFile_PrUrls_FiltersCorrectly() { // Arrange - profile argument is a text file containing fully-qualified PR URLs - var configContent = $""" + var configContent = + $""" bundle: directory: {_changelogDir} use_local_changelogs: true @@ -3463,12 +3504,7 @@ await FileSystem.File.WriteAllTextAsync( // Profile writes to _changelogDir/bundle.yaml because bundle.directory is the fallback for output_directory var expectedOutputPath = FileSystem.Path.Join(_changelogDir, "bundle.yaml"); - var input = new BundleChangelogsArguments - { - Config = configPath, - Profile = "release", - ProfileArgument = urlFile - }; + var input = new BundleChangelogsArguments { Config = configPath, Profile = "release", ProfileArgument = urlFile }; // Act var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -3486,7 +3522,8 @@ await FileSystem.File.WriteAllTextAsync( public async Task BundleChangelogs_WithProfile_UrlListFile_IssueUrls_FiltersCorrectly() { // Arrange - profile argument is a text file containing fully-qualified issue URLs - var configContent = $""" + var configContent = + $""" bundle: directory: {_changelogDir} use_local_changelogs: true @@ -3540,12 +3577,7 @@ await FileSystem.File.WriteAllTextAsync( // Profile writes to _changelogDir/bundle.yaml because bundle.directory is the fallback for output_directory var expectedOutputPath = FileSystem.Path.Join(_changelogDir, "bundle.yaml"); - var input = new BundleChangelogsArguments - { - Config = configPath, - Profile = "release", - ProfileArgument = urlFile - }; + var input = new BundleChangelogsArguments { Config = configPath, Profile = "release", ProfileArgument = urlFile }; // Act var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -3575,7 +3607,8 @@ public async Task BundleChangelogs_WithProfile_UrlListFile_Numbers_ReturnsError( await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); var changelogFile = FileSystem.Path.Join(_changelogDir, "1755268130-feature.yaml"); - await FileSystem.File.WriteAllTextAsync(changelogFile, + await FileSystem.File.WriteAllTextAsync( + changelogFile, """ title: Feature type: feature @@ -3585,7 +3618,9 @@ await FileSystem.File.WriteAllTextAsync(changelogFile, lifecycle: ga prs: - https://github.com/elastic/elasticsearch/pull/100 - """, TestContext.Current.CancellationToken); + """, + TestContext.Current.CancellationToken + ); var urlFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "prs.txt"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(urlFile)!); @@ -3605,11 +3640,12 @@ await FileSystem.File.WriteAllTextAsync(changelogFile, // Assert result.Should().BeFalse("Should fail when file contains bare numbers"); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && - d.Message.Contains("fully-qualified GitHub URLs"), - "Error should mention fully-qualified URLs requirement" - ); + Collector.Diagnostics + .Should() + .Contain( + d => d.Severity == Severity.Error && d.Message.Contains("fully-qualified GitHub URLs"), + "Error should mention fully-qualified URLs requirement" + ); } [Fact] @@ -3628,7 +3664,8 @@ public async Task BundleChangelogs_WithProfile_UrlListFile_MixedPrsAndIssues_Ret await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); var changelogFile = FileSystem.Path.Join(_changelogDir, "1755268130-feature.yaml"); - await FileSystem.File.WriteAllTextAsync(changelogFile, + await FileSystem.File.WriteAllTextAsync( + changelogFile, """ title: Feature type: feature @@ -3638,7 +3675,9 @@ await FileSystem.File.WriteAllTextAsync(changelogFile, lifecycle: ga prs: - https://github.com/elastic/elasticsearch/pull/100 - """, TestContext.Current.CancellationToken); + """, + TestContext.Current.CancellationToken + ); var urlFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "mixed.txt"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(urlFile)!); @@ -3662,11 +3701,12 @@ await FileSystem.File.WriteAllTextAsync( // Assert result.Should().BeFalse("Should fail when file mixes PR and issue URLs"); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && - d.Message.Contains("only pull request URLs or only issue URLs"), - "Error should mention homogeneous URL requirement" - ); + Collector.Diagnostics + .Should() + .Contain( + d => d.Severity == Severity.Error && d.Message.Contains("only pull request URLs or only issue URLs"), + "Error should mention homogeneous URL requirement" + ); } [Fact] @@ -3731,8 +3771,10 @@ await FileSystem.File.WriteAllTextAsync( Directory = _changelogDir, Config = configPath, Profile = "serverless-release", - ProfileArgument = "2026-02", // version string - ProfileReport = urlFile, // URL list file (Phase 3.4) + ProfileArgument = "2026-02", // version string + + ProfileReport = urlFile, // URL list file (Phase 3.4) + OutputDirectory = outputDir }; @@ -3780,7 +3822,11 @@ public async Task BundleChangelogs_WithProfile_CombinedVersion_ReportArgLooksLik var urlFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "prs.txt"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(urlFile)!); - await FileSystem.File.WriteAllTextAsync(urlFile, "https://github.com/elastic/cloud/pull/100\n", TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync( + urlFile, + "https://github.com/elastic/cloud/pull/100\n", + TestContext.Current.CancellationToken + ); // Act: profileArg is a file (should be version), profileReport is a URL file — report arg and version arg are swapped var input = new BundleChangelogsArguments @@ -3788,7 +3834,8 @@ public async Task BundleChangelogs_WithProfile_CombinedVersion_ReportArgLooksLik Directory = _changelogDir, Config = configPath, Profile = "serverless-release", - ProfileArgument = reportFile, // wrong — this looks like a file, should be a version + ProfileArgument = reportFile, // wrong — this looks like a file, should be a version + ProfileReport = urlFile }; @@ -3797,11 +3844,12 @@ public async Task BundleChangelogs_WithProfile_CombinedVersion_ReportArgLooksLik // Assert result.Should().BeFalse("Should fail when first arg looks like a report"); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && - d.Message.Contains("version string"), - "Error should mention that the first arg should be the version" - ); + Collector.Diagnostics + .Should() + .Contain( + d => d.Severity == Severity.Error && d.Message.Contains("version string"), + "Error should mention that the first arg should be the version" + ); } [Fact] @@ -3822,7 +3870,11 @@ public async Task BundleChangelogs_WithProfile_CombinedVersion_ProfileHasProduct var urlFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "prs.txt"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(urlFile)!); - await FileSystem.File.WriteAllTextAsync(urlFile, "https://github.com/elastic/elasticsearch/pull/100\n", TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync( + urlFile, + "https://github.com/elastic/elasticsearch/pull/100\n", + TestContext.Current.CancellationToken + ); var input = new BundleChangelogsArguments { @@ -3837,11 +3889,12 @@ public async Task BundleChangelogs_WithProfile_CombinedVersion_ProfileHasProduct result.Should().BeFalse("Should fail when profile has products pattern and a report is also provided"); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && - d.Message.Contains("products"), - "Error should mention the products pattern conflict" - ); + Collector.Diagnostics + .Should() + .Contain( + d => d.Severity == Severity.Error && d.Message.Contains("products"), + "Error should mention the products pattern conflict" + ); } // ─── Phase 4: --report option (option-based mode) ───────────────────────────────── @@ -3894,12 +3947,7 @@ public async Task BundleChangelogs_WithReportOption_ParsesPromotionReportAndFilt var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); - var input = new BundleChangelogsArguments - { - Directory = _changelogDir, - Report = reportFile, - Output = outputPath - }; + var input = new BundleChangelogsArguments { Directory = _changelogDir, Report = reportFile, Output = outputPath }; // Act var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -3916,11 +3964,7 @@ public async Task BundleChangelogs_WithReportOption_ParsesPromotionReportAndFilt [Fact] public async Task BundleChangelogs_WithReportOption_FileNotFound_ReturnsError() { - var input = new BundleChangelogsArguments - { - Directory = _changelogDir, - Report = "/nonexistent/path/report.html" - }; + var input = new BundleChangelogsArguments { Directory = _changelogDir, Report = "/nonexistent/path/report.html" }; var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -3939,7 +3983,8 @@ public async Task BundleChangelogs_WithPrsFile_ContainingNumbers_ReturnsError() await FileSystem.File.WriteAllTextAsync(prsFile, "100\n200\n", TestContext.Current.CancellationToken); var changelogFile = FileSystem.Path.Join(_changelogDir, "1755268130-feature.yaml"); - await FileSystem.File.WriteAllTextAsync(changelogFile, + await FileSystem.File.WriteAllTextAsync( + changelogFile, """ title: Feature type: feature @@ -3949,7 +3994,9 @@ await FileSystem.File.WriteAllTextAsync(changelogFile, lifecycle: ga prs: - https://github.com/elastic/elasticsearch/pull/100 - """, TestContext.Current.CancellationToken); + """, + TestContext.Current.CancellationToken + ); var input = new BundleChangelogsArguments { @@ -3964,11 +4011,12 @@ await FileSystem.File.WriteAllTextAsync(changelogFile, // Assert result.Should().BeFalse("Should fail when prs file contains bare numbers"); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && - d.Message.Contains("fully-qualified GitHub URLs"), - "Error should mention fully-qualified URL requirement" - ); + Collector.Diagnostics + .Should() + .Contain( + d => d.Severity == Severity.Error && d.Message.Contains("fully-qualified GitHub URLs"), + "Error should mention fully-qualified URL requirement" + ); } [Fact] @@ -3980,7 +4028,8 @@ public async Task BundleChangelogs_WithIssuesFile_ContainingShortForms_ReturnsEr await FileSystem.File.WriteAllTextAsync(issuesFile, "elastic/elasticsearch#100\n", TestContext.Current.CancellationToken); var changelogFile = FileSystem.Path.Join(_changelogDir, "1755268130-feature.yaml"); - await FileSystem.File.WriteAllTextAsync(changelogFile, + await FileSystem.File.WriteAllTextAsync( + changelogFile, """ title: Feature type: feature @@ -3990,7 +4039,9 @@ await FileSystem.File.WriteAllTextAsync(changelogFile, lifecycle: ga issues: - https://github.com/elastic/elasticsearch/issues/100 - """, TestContext.Current.CancellationToken); + """, + TestContext.Current.CancellationToken + ); var input = new BundleChangelogsArguments { @@ -4005,11 +4056,12 @@ await FileSystem.File.WriteAllTextAsync(changelogFile, // Assert result.Should().BeFalse("Should fail when issues file contains short forms"); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && - d.Message.Contains("fully-qualified GitHub URLs"), - "Error should mention fully-qualified URL requirement" - ); + Collector.Diagnostics + .Should() + .Contain( + d => d.Severity == Severity.Error && d.Message.Contains("fully-qualified GitHub URLs"), + "Error should mention fully-qualified URL requirement" + ); } [Fact] @@ -4042,12 +4094,7 @@ await FileSystem.File.WriteAllTextAsync( var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); - var input = new BundleChangelogsArguments - { - Directory = _changelogDir, - Prs = [prsFile], - Output = outputPath - }; + var input = new BundleChangelogsArguments { Directory = _changelogDir, Prs = [prsFile], Output = outputPath }; var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -4060,8 +4107,7 @@ public async Task BundleChangelogs_WithRulesBundleExclude_ExcludesMatchingProduc { // Arrange // language=yaml - var configContent = - """ + var configContent = """ rules: bundle: exclude_products: cloud-hosted @@ -4105,13 +4151,7 @@ public async Task BundleChangelogs_WithRulesBundleExclude_ExcludesMatchingProduc var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); - var input = new BundleChangelogsArguments - { - Directory = changelogDir, - All = true, - Config = configPath, - Output = outputPath - }; + var input = new BundleChangelogsArguments { Directory = changelogDir, All = true, Config = configPath, Output = outputPath }; // Act var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -4132,8 +4172,7 @@ public async Task BundleChangelogs_WithRulesBundleInclude_IncludesOnlyMatchingPr { // Arrange // language=yaml - var configContent = - """ + var configContent = """ rules: bundle: include_products: elasticsearch @@ -4177,13 +4216,7 @@ public async Task BundleChangelogs_WithRulesBundleInclude_IncludesOnlyMatchingPr var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); - var input = new BundleChangelogsArguments - { - Directory = changelogDir, - All = true, - Config = configPath, - Output = outputPath - }; + var input = new BundleChangelogsArguments { Directory = changelogDir, All = true, Config = configPath, Output = outputPath }; // Act var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -4203,8 +4236,7 @@ public async Task BundleChangelogs_WithAllFilter_AppliesRulesBundle() { // Arrange - rules.bundle applies to --all primary filter too // language=yaml - var configContent = - """ + var configContent = """ rules: bundle: exclude_products: kibana @@ -4248,13 +4280,7 @@ public async Task BundleChangelogs_WithAllFilter_AppliesRulesBundle() var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); - var input = new BundleChangelogsArguments - { - Directory = changelogDir, - All = true, - Config = configPath, - Output = outputPath - }; + var input = new BundleChangelogsArguments { Directory = changelogDir, All = true, Config = configPath, Output = outputPath }; // Act var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -4285,7 +4311,8 @@ public async Task BundleChangelogs_WithGlobalExcludeProductsMatchConjunction_Exc FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); - var kibanaOnly = """ + var kibanaOnly = + """ title: Kibana only type: feature products: @@ -4295,7 +4322,8 @@ public async Task BundleChangelogs_WithGlobalExcludeProductsMatchConjunction_Exc prs: - https://github.com/elastic/kibana/pull/100 """; - var esAndKibana = """ + var esAndKibana = + """ title: Elasticsearch and Kibana type: feature products: @@ -4312,22 +4340,18 @@ public async Task BundleChangelogs_WithGlobalExcludeProductsMatchConjunction_Exc await FileSystem.File.WriteAllTextAsync( FileSystem.Path.Join(changelogDir, "1755268001-kibana-only.yaml"), kibanaOnly, - TestContext.Current.CancellationToken); + TestContext.Current.CancellationToken + ); await FileSystem.File.WriteAllTextAsync( FileSystem.Path.Join(changelogDir, "1755268002-es-kibana.yaml"), esAndKibana, - TestContext.Current.CancellationToken); + TestContext.Current.CancellationToken + ); var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); - var input = new BundleChangelogsArguments - { - Directory = changelogDir, - All = true, - Config = configPath, - Output = outputPath - }; + var input = new BundleChangelogsArguments { Directory = changelogDir, All = true, Config = configPath, Output = outputPath }; var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -4356,7 +4380,8 @@ public async Task BundleChangelogs_WithGlobalIncludeProductsMatchConjunction_Req FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); - var esOnly = """ + var esOnly = + """ title: ES only type: feature products: @@ -4366,7 +4391,8 @@ public async Task BundleChangelogs_WithGlobalIncludeProductsMatchConjunction_Req prs: - https://github.com/elastic/elasticsearch/pull/400 """; - var esSec = """ + var esSec = + """ title: ES and security type: feature products: @@ -4382,22 +4408,18 @@ public async Task BundleChangelogs_WithGlobalIncludeProductsMatchConjunction_Req await FileSystem.File.WriteAllTextAsync( FileSystem.Path.Join(changelogDir, "1755268011-es-only.yaml"), esOnly, - TestContext.Current.CancellationToken); + TestContext.Current.CancellationToken + ); await FileSystem.File.WriteAllTextAsync( FileSystem.Path.Join(changelogDir, "1755268012-es-sec.yaml"), esSec, - TestContext.Current.CancellationToken); + TestContext.Current.CancellationToken + ); var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); - var input = new BundleChangelogsArguments - { - Directory = changelogDir, - All = true, - Config = configPath, - Output = outputPath - }; + var input = new BundleChangelogsArguments { Directory = changelogDir, All = true, Config = configPath, Output = outputPath }; var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -4414,8 +4436,7 @@ public async Task BundleChangelogs_WithInputProducts_AppliesBundleRules() { // Arrange - rules.bundle always applies regardless of input method // language=yaml - var configContent = - """ + var configContent = """ rules: bundle: exclude_products: elasticsearch @@ -4459,7 +4480,9 @@ public async Task BundleChangelogs_WithInputProducts_AppliesBundleRules() // Assert - elasticsearch entry is excluded by exclude_products rule even with InputProducts result.Should().BeFalse("Bundle should fail because all entries are excluded by rules.bundle"); - Collector.Diagnostics.Should().Contain(d => d.Message.Contains("[-bundle-exclude]") && d.Message.Contains("1755268130-elasticsearch-feature.yaml")); + Collector.Diagnostics + .Should() + .Contain(d => d.Message.Contains("[-bundle-exclude]") && d.Message.Contains("1755268130-elasticsearch-feature.yaml")); Collector.Errors.Should().BeGreaterThan(0, "Should have error about no entries remaining"); } @@ -4468,8 +4491,7 @@ public async Task BundleChangelogs_WithRulesBundleExcludeType_ExcludesMatchingTy { // Arrange // language=yaml - var configContent = - """ + var configContent = """ rules: bundle: exclude_types: enhancement @@ -4513,13 +4535,7 @@ public async Task BundleChangelogs_WithRulesBundleExcludeType_ExcludesMatchingTy var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); - var input = new BundleChangelogsArguments - { - Directory = changelogDir, - All = true, - Config = configPath, - Output = outputPath - }; + var input = new BundleChangelogsArguments { Directory = changelogDir, All = true, Config = configPath, Output = outputPath }; // Act var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -4539,8 +4555,7 @@ public async Task BundleChangelogs_WithRulesBundleIncludeArea_ExcludesNonMatchin { // Arrange // language=yaml - var configContent = - """ + var configContent = """ rules: bundle: include_areas: "Search" @@ -4588,13 +4603,7 @@ public async Task BundleChangelogs_WithRulesBundleIncludeArea_ExcludesNonMatchin var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); - var input = new BundleChangelogsArguments - { - Directory = changelogDir, - All = true, - Config = configPath, - Output = outputPath - }; + var input = new BundleChangelogsArguments { Directory = changelogDir, All = true, Config = configPath, Output = outputPath }; // Act var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -4679,13 +4688,7 @@ public async Task BundleChangelogs_WithRulesBundlePerProductOverride_AppliesProd var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); - var input = new BundleChangelogsArguments - { - Directory = changelogDir, - All = true, - Config = configPath, - Output = outputPath - }; + var input = new BundleChangelogsArguments { Directory = changelogDir, All = true, Config = configPath, Output = outputPath }; // Act var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -4785,7 +4788,11 @@ public async Task BundleChangelogs_WithOutputProducts_SingleProductEntry_UsesMat All = true, Config = configPath, Output = outputPath, - OutputProducts = [new ProductArgument { Product = "kibana", Target = "9.3.0" }, new ProductArgument { Product = "security", Target = "9.3.0" }] + OutputProducts = + [ + new ProductArgument { Product = "kibana", Target = "9.3.0" }, + new ProductArgument { Product = "security", Target = "9.3.0" } + ] }; // Act @@ -4794,9 +4801,11 @@ public async Task BundleChangelogs_WithOutputProducts_SingleProductEntry_UsesMat // Assert // Rule context = "kibana" (first alphabetically from output products) // All security entries are disjoint from kibana context → excluded - // Kibana entry uses kibana rules (exclude docs) → excluded + // Kibana entry uses kibana rules (exclude docs) → excluded // Result: No entries remain → bundle should fail - result.Should().BeFalse($"Expected bundle to fail when no entries remain. Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + result.Should().BeFalse( + $"Expected bundle to fail when no entries remain. Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}" + ); Collector.Errors.Should().BeGreaterThan(0, "Should have error when no entries remain after filtering"); } @@ -4871,7 +4880,11 @@ public async Task BundleChangelogs_WithOutputProducts_SharedProductEntry_UsesAlp All = true, Config = configPath, Output = outputPath, - OutputProducts = [new ProductArgument { Product = "kibana", Target = "9.3.0" }, new ProductArgument { Product = "security", Target = "9.3.0" }] + OutputProducts = + [ + new ProductArgument { Product = "kibana", Target = "9.3.0" }, + new ProductArgument { Product = "security", Target = "9.3.0" } + ] }; // Act @@ -4881,8 +4894,14 @@ public async Task BundleChangelogs_WithOutputProducts_SharedProductEntry_UsesAlp result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); Collector.Errors.Should().Be(0); var bundleContent = await FileSystem.File.ReadAllTextAsync(outputPath, TestContext.Current.CancellationToken); - bundleContent.Should().NotContain("name: 1755268160-shared.yaml", "kibana rule (alphabetically first) should exclude the shared entry"); - bundleContent.Should().Contain("name: 1755268161-kibana-other.yaml", "kibana entry with a different area should pass the exclude_areas rule"); + bundleContent.Should().NotContain( + "name: 1755268160-shared.yaml", + "kibana rule (alphabetically first) should exclude the shared entry" + ); + bundleContent.Should().Contain( + "name: 1755268161-kibana-other.yaml", + "kibana entry with a different area should pass the exclude_areas rule" + ); Collector.Diagnostics.Should().Contain(d => d.Message.Contains("[-bundle-type-area]")); } @@ -4956,7 +4975,10 @@ public async Task BundleChangelogs_WithoutOutputProducts_FallsBackToEntryProduct var bundleContent = await FileSystem.File.ReadAllTextAsync(outputPath, TestContext.Current.CancellationToken); bundleContent.Should().NotContain("name: 1755268170-kibana-doc.yaml", "kibana docs should be excluded by its per-product rule"); - bundleContent.Should().Contain("name: 1755268180-es-doc.yaml", "elasticsearch docs entry has no per-product rule and no global rule, so it is included"); + bundleContent.Should().Contain( + "name: 1755268180-es-doc.yaml", + "elasticsearch docs entry has no per-product rule and no global rule, so it is included" + ); } [Fact] @@ -5014,7 +5036,10 @@ public async Task BundleChangelogs_WithOutputProducts_EntryNotInContext_FallsBac Collector.Errors.Should().Be(1, "system reports error when no entries remain after filtering"); var errorMessages = string.Join("; ", Collector.Diagnostics.Select(d => d.Message)); - errorMessages.Should().Contain("disjoint from rule context 'kibana'", "elasticsearch entry should be excluded as disjoint from kibana context"); + errorMessages.Should().Contain( + "disjoint from rule context 'kibana'", + "elasticsearch entry should be excluded as disjoint from kibana context" + ); errorMessages.Should().Contain("No changelog entries remained", "system should report empty bundle error"); } @@ -5066,11 +5091,17 @@ public async Task BundleChangelogs_WithPerProductIncludeProducts_IncludesOnlyCon var bundleContent = await FileSystem.File.ReadAllTextAsync(outputPath, TestContext.Current.CancellationToken); // Only security changelog should be included (it matches the bundle context "security") - bundleContent.Should().Contain("security-feature.yaml", "security entry matches bundle context and should be included by security-specific rules"); + bundleContent.Should().Contain( + "security-feature.yaml", + "security entry matches bundle context and should be included by security-specific rules" + ); // Disjoint changelogs are excluded entirely (not included via global fallback) bundleContent.Should().NotContain("kibana-feature.yaml", "kibana entry is disjoint from security context and should be excluded"); - bundleContent.Should().NotContain("elasticsearch-feature.yaml", "elasticsearch entry is disjoint from security context and should be excluded"); + bundleContent.Should().NotContain( + "elasticsearch-feature.yaml", + "elasticsearch entry is disjoint from security context and should be excluded" + ); } [Fact] @@ -5099,7 +5130,8 @@ public async Task BundleChangelogs_WithPerProductExcludeProducts_ExcludesContext await CreateTestEntry(changelogDir, "elasticsearch-feature.yaml", "Elasticsearch feature", "elasticsearch"); // Create multi-product entry that should be excluded by security context rule - var multiProductContent = """ + var multiProductContent = + """ title: Security+Kibana feature type: feature products: @@ -5140,7 +5172,10 @@ public async Task BundleChangelogs_WithPerProductExcludeProducts_ExcludesContext bundleContent.Should().Contain("security-feature.yaml", "security entry should be included (not in context exclude list)"); // Multi-product entry (security + kibana) matches security context and gets excluded by exclude_products=[kibana] → EXCLUDED - bundleContent.Should().NotContain("security-kibana-feature.yaml", "security+kibana entry should be excluded by security context rule"); + bundleContent.Should().NotContain( + "security-kibana-feature.yaml", + "security+kibana entry should be excluded by security context rule" + ); } [Fact] @@ -5193,7 +5228,10 @@ public async Task BundleChangelogs_WithPerProductRules_FallsBackToGlobalWhenNoCo // Security-only entry is included (disjoint satisfied; no per-product product filter) result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); var bundleContent = await FileSystem.File.ReadAllTextAsync(outputPath, TestContext.Current.CancellationToken); - bundleContent.Should().Contain("security-feature.yaml", "security entry should be included (Mode 3 pass-through when no per-product block for context)"); + bundleContent.Should().Contain( + "security-feature.yaml", + "security entry should be included (Mode 3 pass-through when no per-product block for context)" + ); // Disjoint entries are excluded entirely in single-product rule resolution bundleContent.Should().NotContain("elasticsearch-feature.yaml", "elasticsearch entry is disjoint from security context"); bundleContent.Should().NotContain("kibana-feature.yaml", "kibana entry is disjoint from security context"); @@ -5232,6 +5270,7 @@ public async Task BundleChangelogs_WithPerProductRules_ContextRulesTakePrecedenc { Directory = changelogDir, InputProducts = [new ProductArgument { Product = "*" }], // Input method should not affect bundle filtering + Config = configPath, Output = outputPath, OutputProducts = [new ProductArgument { Product = "security", Target = "9.3.0" }] @@ -5255,7 +5294,8 @@ public async Task BundleChangelogs_WithPerProductRules_ContextRulesTakePrecedenc private async Task CreateTestEntry(string changelogDir, string filename, string title, string product) { - var content = $""" + var content = + $""" title: {title} type: feature products: @@ -5289,8 +5329,7 @@ public async Task BundleChangelogs_WithNoProductsField_FallsBackToGlobalRules() // Arrange — global-only rules.bundle (Mode 2): entries with no products get a warning; product filters are skipped; // type/area blocker still applies. // language=yaml - var configContent = - """ + var configContent = """ rules: bundle: exclude_types: @@ -5316,13 +5355,7 @@ public async Task BundleChangelogs_WithNoProductsField_FallsBackToGlobalRules() var outputPath = CreateTempFilePath("no-products-bundle.yaml"); - var input = new BundleChangelogsArguments - { - All = true, - Directory = changelogDir, - Config = configPath, - Output = outputPath - }; + var input = new BundleChangelogsArguments { All = true, Directory = changelogDir, Config = configPath, Output = outputPath }; // Act var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -5387,13 +5420,7 @@ public async Task BundleChangelogs_GlobalMode_IncludeProductsAny_IncludesEntryMa await FileSystem.File.WriteAllTextAsync(file2, kibanaOnly, TestContext.Current.CancellationToken); var outputPath = CreateTempFilePath("global-or-bundle.yaml"); - var input = new BundleChangelogsArguments - { - All = true, - Directory = changelogDir, - Config = configPath, - Output = outputPath - }; + var input = new BundleChangelogsArguments { All = true, Directory = changelogDir, Config = configPath, Output = outputPath }; var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -5423,8 +5450,7 @@ public async Task BundleChangelogs_GlobalMode_EmptyProducts_WarnsThenFailsResolv FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); - var noProductsEntry = - """ + var noProductsEntry = """ title: No products type: feature prs: @@ -5436,13 +5462,7 @@ public async Task BundleChangelogs_GlobalMode_EmptyProducts_WarnsThenFailsResolv await FileSystem.File.WriteAllTextAsync(file1, noProductsEntry, TestContext.Current.CancellationToken); var outputPath = CreateTempFilePath("global-empty-products.yaml"); - var input = new BundleChangelogsArguments - { - All = true, - Directory = changelogDir, - Config = configPath, - Output = outputPath - }; + var input = new BundleChangelogsArguments { All = true, Directory = changelogDir, Config = configPath, Output = outputPath }; var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -5493,17 +5513,19 @@ public async Task BundleChangelogs_WithEmptyProductsYamlMap_UsesGlobalRulesWhenG """; var changelogDir = CreateChangelogDir(); - await FileSystem.File.WriteAllTextAsync(FileSystem.Path.Join(changelogDir, "1755268208-es.yaml"), es, TestContext.Current.CancellationToken); - await FileSystem.File.WriteAllTextAsync(FileSystem.Path.Join(changelogDir, "1755268209-kibana.yaml"), kibana, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync( + FileSystem.Path.Join(changelogDir, "1755268208-es.yaml"), + es, + TestContext.Current.CancellationToken + ); + await FileSystem.File.WriteAllTextAsync( + FileSystem.Path.Join(changelogDir, "1755268209-kibana.yaml"), + kibana, + TestContext.Current.CancellationToken + ); var outputPath = CreateTempFilePath("empty-products-map-bundle.yaml"); - var input = new BundleChangelogsArguments - { - All = true, - Directory = changelogDir, - Config = configPath, - Output = outputPath - }; + var input = new BundleChangelogsArguments { All = true, Directory = changelogDir, Config = configPath, Output = outputPath }; var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -5551,13 +5573,7 @@ public async Task BundleChangelogs_WithEmptyProductsList_FallsBackToGlobalRules( var outputPath = CreateTempFilePath("empty-products-bundle.yaml"); - var input = new BundleChangelogsArguments - { - All = true, - Directory = changelogDir, - Config = configPath, - Output = outputPath - }; + var input = new BundleChangelogsArguments { All = true, Directory = changelogDir, Config = configPath, Output = outputPath }; // Act var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -5572,7 +5588,10 @@ public async Task BundleChangelogs_WithEmptyProductsList_FallsBackToGlobalRules( var errorMessages = string.Join("; ", Collector.Diagnostics.Select(d => d.Message)); errorMessages.Should().Contain("Bundle has no product context", "bundle validation should report lack of product context"); - errorMessages.Should().Contain("No changelog entries remained after applying rules.bundle filter", "system should report empty bundle error"); + errorMessages.Should().Contain( + "No changelog entries remained after applying rules.bundle filter", + "system should report empty bundle error" + ); } [Fact] @@ -5626,7 +5645,11 @@ public async Task BundleChangelogs_WithMultipleProducts_UnifiedProductFiltering_ Directory = changelogDir, Config = configPath, Output = outputPath, - OutputProducts = [new ProductArgument { Product = "kibana", Target = "9.3.0" }, new ProductArgument { Product = "security", Target = "9.3.0" }] + OutputProducts = + [ + new ProductArgument { Product = "kibana", Target = "9.3.0" }, + new ProductArgument { Product = "security", Target = "9.3.0" } + ] }; // Act @@ -5781,7 +5804,10 @@ public async Task BundleChangelogs_MultiProductDisjoint_UsesGlobalRules() Collector.Errors.Should().Be(1, "system reports error when no entries remain after filtering"); var errorMessages = string.Join("; ", Collector.Diagnostics.Select(d => d.Message)); - errorMessages.Should().Contain("disjoint from rule context 'security'", "disjoint entry should be excluded with informative message"); + errorMessages.Should().Contain( + "disjoint from rule context 'security'", + "disjoint entry should be excluded with informative message" + ); errorMessages.Should().Contain("No changelog entries remained", "system should report empty bundle error"); } @@ -5827,7 +5853,7 @@ public async Task BundleChangelogs_BundleAll_DisjointUsesOwnProductRules() """; // Multi-product entry - should be excluded by elasticsearch rule (alphabetically first) - // language=yaml + // language=yaml var multiProductEntry = """ title: Multi-product entry with elasticsearch @@ -5878,8 +5904,10 @@ public async Task BundleChangelogs_BundleAll_DisjointUsesOwnProductRules() // Assert - rule context = "elasticsearch" (first alphabetically from aggregated products) // Security entry is disjoint from elasticsearch context → excluded - // All elasticsearch entries are excluded by elasticsearch rule → no entries remain → bundle fails - result.Should().BeFalse($"Expected bundle to fail when no entries remain. Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + // All elasticsearch entries are excluded by elasticsearch rule → no entries remain → bundle fails + result.Should().BeFalse( + $"Expected bundle to fail when no entries remain. Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}" + ); Collector.Errors.Should().BeGreaterThan(0, "Should have error when no entries remain after filtering"); } @@ -5924,13 +5952,7 @@ public async Task BundleChangelogs_PartialPerProductRules_AllOrNothingReplacemen var outputPath = CreateTempFilePath("partial-rule-bundle.yaml"); - var input = new BundleChangelogsArguments - { - All = true, - Directory = changelogDir, - Config = configPath, - Output = outputPath - }; + var input = new BundleChangelogsArguments { All = true, Directory = changelogDir, Config = configPath, Output = outputPath }; // Act var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -5942,7 +5964,10 @@ public async Task BundleChangelogs_PartialPerProductRules_AllOrNothingReplacemen Collector.Errors.Should().Be(0, "no errors expected when entry is included"); var bundleContent = await FileSystem.File.ReadAllTextAsync(outputPath, TestContext.Current.CancellationToken); - bundleContent.Should().Contain("1755268204-partial-rule.yaml", "entry should be included - per-product rule ignores global type exclusions"); + bundleContent.Should().Contain( + "1755268204-partial-rule.yaml", + "entry should be included - per-product rule ignores global type exclusions" + ); } [Fact] @@ -5965,8 +5990,14 @@ public async Task BundleChangelogs_OptionModeWithPlaceholdersButNoOutputProducts // Assert result.Should().BeFalse("bundling should fail when placeholders are used without --output-products"); Collector.Errors.Should().Be(1, "should have exactly one validation error"); - Collector.Diagnostics.Should().Contain(d => d.Message.Contains( - "When using placeholders in bundle description in option-based mode, --output-products must be explicitly specified to ensure predictable substitution values.")); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Message.Contains( + "When using placeholders in bundle description in option-based mode, --output-products must be explicitly specified to ensure predictable substitution values." + ) + ); } [Fact] @@ -5974,10 +6005,7 @@ public async Task BundleChangelogs_OptionModeWithPlaceholdersAndOutputProducts_S { // Arrange CreateSampleChangelogs(); - var outputProducts = new List - { - new() { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" } - }; + var outputProducts = new List { new() { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" } }; var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); var input = new BundleChangelogsArguments @@ -5999,8 +6027,10 @@ public async Task BundleChangelogs_OptionModeWithPlaceholdersAndOutputProducts_S Collector.Errors.Should().Be(0, "no errors expected when validation passes"); var bundleContent = await FileSystem.File.ReadAllTextAsync(outputPath, TestContext.Current.CancellationToken); - bundleContent.Should().Contain("Release includes 9.2.0 with ga features from elastic/elasticsearch", - "placeholders should be substituted correctly"); + bundleContent.Should().Contain( + "Release includes 9.2.0 with ga features from elastic/elasticsearch", + "placeholders should be substituted correctly" + ); } [Fact] @@ -6023,11 +6053,16 @@ public async Task BundleChangelogs_OptionModeWithConfigDescriptionAndPlaceholder // Assert result.Should().BeFalse("bundling should fail when description has placeholders without --output-products"); Collector.Errors.Should().Be(1, "should have exactly one validation error"); - Collector.Diagnostics.Should().Contain(d => d.Message.Contains( - "When using placeholders in bundle description in option-based mode, --output-products must be explicitly specified to ensure predictable substitution values.")); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Message.Contains( + "When using placeholders in bundle description in option-based mode, --output-products must be explicitly specified to ensure predictable substitution values." + ) + ); } - [Fact] public async Task BundleChangelogs_WithBundleReleaseDatesFalse_SuppressesReleaseDate() { @@ -6040,7 +6075,8 @@ public async Task BundleChangelogs_WithBundleReleaseDatesFalse_SuppressesRelease FileSystem.Directory.CreateDirectory(docsDir); var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); // language=yaml - await FileSystem.File.WriteAllTextAsync(configPath, + await FileSystem.File.WriteAllTextAsync( + configPath, """ bundle: release_dates: false @@ -6049,13 +6085,7 @@ await FileSystem.File.WriteAllTextAsync(configPath, ); var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); - var input = new BundleChangelogsArguments - { - Directory = _changelogDir, - All = true, - Output = outputPath, - Config = configPath - }; + var input = new BundleChangelogsArguments { Directory = _changelogDir, All = true, Output = outputPath, Config = configPath }; // Act var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -6080,7 +6110,8 @@ public async Task BundleChangelogs_WithBundleReleaseDatesTrue_AutoPopulatesRelea FileSystem.Directory.CreateDirectory(docsDir); var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); // language=yaml - await FileSystem.File.WriteAllTextAsync(configPath, + await FileSystem.File.WriteAllTextAsync( + configPath, """ bundle: release_dates: true @@ -6089,13 +6120,7 @@ await FileSystem.File.WriteAllTextAsync(configPath, ); var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); - var input = new BundleChangelogsArguments - { - Directory = _changelogDir, - All = true, - Output = outputPath, - Config = configPath - }; + var input = new BundleChangelogsArguments { Directory = _changelogDir, All = true, Output = outputPath, Config = configPath }; // Act var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -6137,12 +6162,7 @@ public async Task BundleChangelogs_WithBomPrefixedInput_ProducesNormalizedOutput ChangelogUtf8Normalization.HasUtf8Bom(sourceBytes).Should().BeTrue("source file should contain BOM"); var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); - var input = new BundleChangelogsArguments - { - Directory = _changelogDir, - All = true, - Output = outputPath - }; + var input = new BundleChangelogsArguments { Directory = _changelogDir, All = true, Output = outputPath }; // Act var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleDescriptionSubstitutionTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleDescriptionSubstitutionTests.cs index 99353bc81a..f1a3c479c0 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleDescriptionSubstitutionTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleDescriptionSubstitutionTests.cs @@ -14,7 +14,11 @@ public void SubstitutePlaceholders_AllPlaceholdersResolved_ReturnsSubstitutedStr { var result = BundleDescriptionSubstitution.SubstitutePlaceholders( "Release {version} ({lifecycle}) from {owner}/{repo}", - "9.2.0", "ga", "elastic", "elasticsearch"); + "9.2.0", + "ga", + "elastic", + "elasticsearch" + ); result.Should().Be("Release 9.2.0 (ga) from elastic/elasticsearch"); } @@ -22,8 +26,7 @@ public void SubstitutePlaceholders_AllPlaceholdersResolved_ReturnsSubstitutedStr [Fact] public void SubstitutePlaceholders_NullValues_ReplacedWithEmptyString() { - var result = BundleDescriptionSubstitution.SubstitutePlaceholders( - "Version {version} by {owner}", null, null, null, null); + var result = BundleDescriptionSubstitution.SubstitutePlaceholders("Version {version} by {owner}", null, null, null, null); result.Should().Be("Version by "); } @@ -31,8 +34,7 @@ public void SubstitutePlaceholders_NullValues_ReplacedWithEmptyString() [Fact] public void SubstitutePlaceholders_EmptyDescription_ReturnsEmpty() { - var result = BundleDescriptionSubstitution.SubstitutePlaceholders( - "", "9.2.0", "ga", "elastic", "elasticsearch"); + var result = BundleDescriptionSubstitution.SubstitutePlaceholders("", "9.2.0", "ga", "elastic", "elasticsearch"); result.Should().BeEmpty(); } @@ -41,7 +43,12 @@ public void SubstitutePlaceholders_EmptyDescription_ReturnsEmpty() public void SubstitutePlaceholders_NoPlaceholders_ReturnsOriginal() { var result = BundleDescriptionSubstitution.SubstitutePlaceholders( - "Just a plain description.", "9.2.0", "ga", "elastic", "elasticsearch"); + "Just a plain description.", + "9.2.0", + "ga", + "elastic", + "elasticsearch" + ); result.Should().Be("Just a plain description."); } @@ -51,7 +58,11 @@ public void SubstitutePlaceholders_PartialPlaceholders_OnlySubstitutesPresent() { var result = BundleDescriptionSubstitution.SubstitutePlaceholders( "Download: https://github.com/{owner}/{repo}/releases", - null, null, "elastic", "elasticsearch"); + null, + null, + "elastic", + "elasticsearch" + ); result.Should().Be("Download: https://github.com/elastic/elasticsearch/releases"); } @@ -59,22 +70,28 @@ public void SubstitutePlaceholders_PartialPlaceholders_OnlySubstitutesPresent() [Fact] public void SubstitutePlaceholders_ValidateResolvable_ThrowsWhenVersionMissing() { - var act = () => BundleDescriptionSubstitution.SubstitutePlaceholders( - "Release {version}", null, null, null, null, validateResolvable: true); + var act = + () => + BundleDescriptionSubstitution.SubstitutePlaceholders("Release {version}", null, null, null, null, validateResolvable: true); - act.Should().Throw() - .WithMessage("*version*"); + act.Should().Throw().WithMessage("*version*"); } [Fact] public void SubstitutePlaceholders_ValidateResolvable_ThrowsWhenMultipleMissing() { - var act = () => BundleDescriptionSubstitution.SubstitutePlaceholders( - "v{version} ({lifecycle}) from {owner}/{repo}", - null, null, null, null, validateResolvable: true); - - act.Should().Throw() - .WithMessage("*version*lifecycle*owner*repo*"); + var act = + () => + BundleDescriptionSubstitution.SubstitutePlaceholders( + "v{version} ({lifecycle}) from {owner}/{repo}", + null, + null, + null, + null, + validateResolvable: true + ); + + act.Should().Throw().WithMessage("*version*lifecycle*owner*repo*"); } [Fact] @@ -82,7 +99,12 @@ public void SubstitutePlaceholders_ValidateResolvable_SucceedsWhenAllProvided() { var result = BundleDescriptionSubstitution.SubstitutePlaceholders( "v{version} from {owner}/{repo}", - "9.2.0", "ga", "elastic", "elasticsearch", validateResolvable: true); + "9.2.0", + "ga", + "elastic", + "elasticsearch", + validateResolvable: true + ); result.Should().Be("v9.2.0 from elastic/elasticsearch"); } @@ -92,7 +114,12 @@ public void SubstitutePlaceholders_ValidateResolvable_IgnoresUnusedNullValues() { var result = BundleDescriptionSubstitution.SubstitutePlaceholders( "Download from {owner}/{repo}", - null, null, "elastic", "elasticsearch", validateResolvable: true); + null, + null, + "elastic", + "elasticsearch", + validateResolvable: true + ); result.Should().Be("Download from elastic/elasticsearch"); } @@ -100,8 +127,7 @@ public void SubstitutePlaceholders_ValidateResolvable_IgnoresUnusedNullValues() [Fact] public void SubstitutePlaceholders_NullDescription_ReturnsNull() { - var result = BundleDescriptionSubstitution.SubstitutePlaceholders( - null!, "9.2.0", "ga", "elastic", "elasticsearch"); + var result = BundleDescriptionSubstitution.SubstitutePlaceholders(null!, "9.2.0", "ga", "elastic", "elasticsearch"); result.Should().BeNull(); } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs index 7ba1ab2e2e..e39d66830c 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs @@ -69,13 +69,7 @@ public async Task Bundle_WithFiles_IncludesOnlyNamedEntries() await FileSystem.File.WriteAllTextAsync(skip, EntrySkip, TestContext.Current.CancellationToken); var output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); - var input = new BundleChangelogsArguments - { - Directory = _changelogDir, - Files = [keep], - Output = output, - ForceLocal = true - }; + var input = new BundleChangelogsArguments { Directory = _changelogDir, Files = [keep], Output = output, ForceLocal = true }; var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -99,8 +93,7 @@ public async Task Bundle_WithFiles_MissingFile_ReturnsError() var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); result.Should().BeFalse(); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && d.Message.Contains("File does not exist")); + Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("File does not exist")); } [Fact] @@ -120,8 +113,7 @@ public async Task Bundle_WithFilesAndPrs_ReturnsMutualExclusivityError() var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); result.Should().BeFalse(); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && d.Message.Contains("Multiple filter options")); + Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("Multiple filter options")); } [Fact] @@ -137,12 +129,7 @@ public async Task Bundle_WithPathListFile_IncludesListedEntries() await FileSystem.File.WriteAllTextAsync(listFile, $"{keep}\n", TestContext.Current.CancellationToken); var output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); - var input = new BundleChangelogsArguments - { - Directory = _changelogDir, - Files = [listFile], - Output = output - }; + var input = new BundleChangelogsArguments { Directory = _changelogDir, Files = [listFile], Output = output }; var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -155,7 +142,8 @@ public async Task Bundle_WithPathListFile_IncludesListedEntries() [Fact] public async Task Bundle_WithProfile_PathListFile_FiltersCorrectly() { - var configContent = $""" + var configContent = + $""" bundle: directory: {_changelogDir} profiles: @@ -211,7 +199,8 @@ public async Task Bundle_WithProfile_MixedUrlsAndPaths_ReturnsError() await FileSystem.File.WriteAllTextAsync( listFile, "https://github.com/elastic/elasticsearch/pull/100\nkeep.yaml\n", - TestContext.Current.CancellationToken); + TestContext.Current.CancellationToken + ); var input = new BundleChangelogsArguments { @@ -224,8 +213,7 @@ await FileSystem.File.WriteAllTextAsync( var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); result.Should().BeFalse(); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && d.Message.Contains("not a mix")); + Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("not a mix")); } [Fact] @@ -236,7 +224,8 @@ public async Task Bundle_WithFiles_RulesBundleStillApplies() await FileSystem.File.WriteAllTextAsync(feature, EntryKeep, TestContext.Current.CancellationToken); await FileSystem.File.WriteAllTextAsync(bugFix, EntryBugFix, TestContext.Current.CancellationToken); - var configContent = $""" + var configContent = + $""" bundle: directory: {_changelogDir} use_local_changelogs: true @@ -249,12 +238,7 @@ public async Task Bundle_WithFiles_RulesBundleStillApplies() await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); var output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); - var input = new BundleChangelogsArguments - { - Config = configPath, - Files = [feature, bugFix], - Output = output - }; + var input = new BundleChangelogsArguments { Config = configPath, Files = [feature, bugFix], Output = output }; var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -270,7 +254,8 @@ public async Task Bundle_WithFilesAndForceLocal_SourcesLocalEvenWhenRepoResolves var keep = FileSystem.Path.Join(_changelogDir, "keep.yaml"); await FileSystem.File.WriteAllTextAsync(keep, EntryKeep, TestContext.Current.CancellationToken); - var configContent = $""" + var configContent = + $""" bundle: directory: {_changelogDir} repo: elasticsearch @@ -284,13 +269,7 @@ public async Task Bundle_WithFilesAndForceLocal_SourcesLocalEvenWhenRepoResolves var service = new ChangelogBundlingService(LoggerFactory, FileSystem, ConfigurationContext, null, fetcher); var output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); - var input = new BundleChangelogsArguments - { - Config = configPath, - Files = [keep], - ForceLocal = true, - Output = output - }; + var input = new BundleChangelogsArguments { Config = configPath, Files = [keep], ForceLocal = true, Output = output }; var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -311,12 +290,7 @@ public async Task Bundle_WithFiles_RepoResolves_MatchesCdnPoolByFileName() var service = ServiceWithCdn(handler); var output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); - var input = new BundleChangelogsArguments - { - Config = configPath, - Files = ["docs/changelog/keep.yaml"], - Output = output - }; + var input = new BundleChangelogsArguments { Config = configPath, Files = ["docs/changelog/keep.yaml"], Output = output }; var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -343,8 +317,13 @@ public async Task Bundle_WithFiles_CdnPoolMissingRequestedName_FailsBundle() var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); result.Should().BeFalse(); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && d.Message.Contains("not found in the CDN pool") && d.Message.Contains("never-uploaded.yaml")); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Severity == Severity.Error && d.Message.Contains("not found in the CDN pool") && + d.Message.Contains("never-uploaded.yaml") + ); } [Fact] @@ -354,7 +333,8 @@ public async Task Bundle_WithProfile_PathListFile_RepoResolves_SourcesFromCdn() // only in S3. The list must select pool entries by file name instead of requiring local files. var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); - var configContent = $""" + var configContent = + $""" bundle: output_directory: {outputDir} repo: elasticsearch @@ -385,32 +365,33 @@ public async Task Bundle_WithProfile_PathListFile_RepoResolves_SourcesFromCdn() result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); handler.RequestedPaths.Should().Contain("/changelog/elastic/elasticsearch/main/registry.json"); - var bundle = await FileSystem.File.ReadAllTextAsync( - FileSystem.Path.Join(outputDir, "bundle.yaml"), TestContext.Current.CancellationToken); + var bundle = + await FileSystem.File.ReadAllTextAsync(FileSystem.Path.Join(outputDir, "bundle.yaml"), TestContext.Current.CancellationToken); bundle.Should().Contain("name: keep.yaml"); bundle.Should().NotContain("name: skip.yaml"); } - private static StubHandler CdnPoolHandler() => new(req => - { - var path = req.RequestUri!.AbsolutePath; - if (path.EndsWith("/registry.json", StringComparison.Ordinal)) - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(CdnRegistryJson, System.Text.Encoding.UTF8, "application/json") - }; - if (path.EndsWith("keep.yaml", StringComparison.Ordinal)) - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(EntryKeep, System.Text.Encoding.UTF8, "text/yaml") - }; - if (path.EndsWith("skip.yaml", StringComparison.Ordinal)) - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(EntrySkip, System.Text.Encoding.UTF8, "text/yaml") - }; - return new HttpResponseMessage(HttpStatusCode.NotFound); - }); + private static StubHandler CdnPoolHandler() => + new(req => + { + var path = req.RequestUri!.AbsolutePath; + if (path.EndsWith("/registry.json", StringComparison.Ordinal)) + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(CdnRegistryJson, System.Text.Encoding.UTF8, "application/json") + }; + if (path.EndsWith("keep.yaml", StringComparison.Ordinal)) + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(EntryKeep, System.Text.Encoding.UTF8, "text/yaml") + }; + if (path.EndsWith("skip.yaml", StringComparison.Ordinal)) + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(EntrySkip, System.Text.Encoding.UTF8, "text/yaml") + }; + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); private ChangelogBundlingService ServiceWithCdn(StubHandler handler) { @@ -421,8 +402,7 @@ private ChangelogBundlingService ServiceWithCdn(StubHandler handler) private async Task WriteRepoOnlyConfigAsync() { // language=yaml - var configContent = - """ + var configContent = """ bundle: repo: elasticsearch """; @@ -438,7 +418,8 @@ public async Task Bundle_WithForceLocal_SourcesLocalDespiteResolvableRepo() var local = FileSystem.Path.Join(_changelogDir, "1-local.yaml"); await FileSystem.File.WriteAllTextAsync(local, EntryKeep, TestContext.Current.CancellationToken); - var configContent = $""" + var configContent = + $""" bundle: directory: {_changelogDir} repo: elasticsearch @@ -452,13 +433,7 @@ public async Task Bundle_WithForceLocal_SourcesLocalDespiteResolvableRepo() var service = new ChangelogBundlingService(LoggerFactory, FileSystem, ConfigurationContext, null, fetcher); var output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); - var input = new BundleChangelogsArguments - { - Config = configPath, - All = true, - ForceLocal = true, - Output = output - }; + var input = new BundleChangelogsArguments { Config = configPath, All = true, ForceLocal = true, Output = output }; var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleGitRefTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleGitRefTests.cs index a3c2373a27..48aad23347 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleGitRefTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleGitRefTests.cs @@ -26,7 +26,8 @@ public class BundleGitRefTests(ITestOutputHelper output) : ChangelogTestBase(out // Pool entry named by PR number (the CI naming scheme for private repos): matches PR 100 by file name. // language=yaml - private const string PoolEntryByName = """ + private const string PoolEntryByName = + """ title: Faster hosted search type: feature products: @@ -37,7 +38,8 @@ public class BundleGitRefTests(ITestOutputHelper output) : ChangelogTestBase(out // Pool entry with a non-numeric name: matches PR 200 only via its prs reference. // language=yaml - private const string PoolEntryByPrs = """ + private const string PoolEntryByPrs = + """ title: Sturdier snapshots type: bug-fix products: @@ -52,33 +54,32 @@ public class BundleGitRefTests(ITestOutputHelper output) : ChangelogTestBase(out private const string RegistryJson = """{ "schema_version": 1, "product": "widget", "bundles": [ { "file": "100.yaml" }, { "file": "sturdier-snapshots.yaml" } ] }"""; - private StubHandler PoolHandler() => new(req => - { - var path = req.RequestUri!.AbsolutePath; - if (path.EndsWith("/registry.json", StringComparison.Ordinal)) - return Json(RegistryJson); - if (path.EndsWith("100.yaml", StringComparison.Ordinal)) - return Yaml(PoolEntryByName); - if (path.EndsWith("sturdier-snapshots.yaml", StringComparison.Ordinal)) - return Yaml(PoolEntryByPrs); - return new HttpResponseMessage(HttpStatusCode.NotFound); - }); + private StubHandler PoolHandler() => + new(req => + { + var path = req.RequestUri!.AbsolutePath; + if (path.EndsWith("/registry.json", StringComparison.Ordinal)) + return Json(RegistryJson); + if (path.EndsWith("100.yaml", StringComparison.Ordinal)) + return Yaml(PoolEntryByName); + if (path.EndsWith("sturdier-snapshots.yaml", StringComparison.Ordinal)) + return Yaml(PoolEntryByPrs); + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); private CdnChangelogEntryFetcher Fetcher(StubHandler handler) => new(new TestLoggerFactory(Output), handler, sleep: (_, _) => Task.CompletedTask); - private static CommitRangePullRequest RangePr(int number) => new() - { - Number = number, - Url = $"https://github.com/elastic/widget/pull/{number}", - CommitShas = [$"sha-{number}"] - }; + private static CommitRangePullRequest RangePr(int number) => + new() { Number = number, Url = $"https://github.com/elastic/widget/pull/{number}", CommitShas = [$"sha-{number}"] }; private static IGitHubCommitRangeService RangeService(params int[] prNumbers) { var service = A.Fake(); - _ = A.CallTo(() => service.ResolvePullRequestsAsync(A._, A._, A._)) - .Returns(new CommitRangeResolution + _ = + A.CallTo( + () => service.ResolvePullRequestsAsync(A._, A._, A._) + ).Returns(new CommitRangeResolution { TotalCommits = prNumbers.Length, PullRequests = prNumbers.Select(RangePr).ToList(), @@ -90,8 +91,7 @@ private static IGitHubCommitRangeService RangeService(params int[] prNumbers) private async Task WriteProfileConfig(string outputDir) { // language=yaml - var configContent = - """ + var configContent = """ pivot: types: feature: ">feature" @@ -104,7 +104,10 @@ private async Task WriteProfileConfig(string outputDir) profiles: promotion: output_products: "cloud-hosted {version}" - """.Replace("PLACEHOLDER", outputDir); + """.Replace( + "PLACEHOLDER", + outputDir + ); var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); @@ -115,9 +118,9 @@ private async Task WriteProfileConfig(string outputDir) private ChangelogBundlingService Service( StubHandler handler, IGitHubCommitRangeService rangeService, - IGitHubPrService? prService = null) => - new(LoggerFactory, FileSystem, ConfigurationContext, null, Fetcher(handler), - prService ?? A.Fake(), rangeService); + IGitHubPrService? prService = null + ) => + new(LoggerFactory, FileSystem, ConfigurationContext, null, Fetcher(handler), prService ?? A.Fake(), rangeService); [Fact] public async Task ProfileMode_PoolFirstWithInferredFallback_WritesBundleWithGitRef() @@ -130,16 +133,20 @@ public async Task ProfileMode_PoolFirstWithInferredFallback_WritesBundleWithGitR // synthesized from PR metadata (release-note text + label-derived type). PR 400: metadata // unavailable — reported missing, bundle still ships. var prService = A.Fake(); - _ = A.CallTo(() => prService.FetchPrInfoAsync("https://github.com/elastic/widget/pull/300", A._, A._, A._)) - .Returns(new GitHubPrInfo + _ = + A.CallTo( + () => prService.FetchPrInfoAsync("https://github.com/elastic/widget/pull/300", A._, A._, A._) + ).Returns(new GitHubPrInfo { Title = "Sharper autocomplete", Body = "Some context.\n\n## Release Note\nAutocomplete now ranks recent indices first.\n\nInternal details.", Labels = [">feature"], LinkedIssues = [] }); - _ = A.CallTo(() => prService.FetchPrInfoAsync("https://github.com/elastic/widget/pull/400", A._, A._, A._)) - .Returns((GitHubPrInfo?)null); + _ = + A.CallTo( + () => prService.FetchPrInfoAsync("https://github.com/elastic/widget/pull/400", A._, A._, A._) + ).Returns((GitHubPrInfo?)null); var service = Service(PoolHandler(), RangeService(100, 200, 300, 400), prService); @@ -154,7 +161,9 @@ public async Task ProfileMode_PoolFirstWithInferredFallback_WritesBundleWithGitR var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); - result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + result.Should().BeTrue( + $"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}" + ); Collector.Errors.Should().Be(0); // No profile output pattern → the standardized {product}-{version}.yaml convention applies. @@ -178,8 +187,9 @@ public async Task ProfileMode_PoolFirstWithInferredFallback_WritesBundleWithGitR bundle.Should().Contain($"git_ref: {EndRef}"); // PR 400 is reported, not silently dropped. - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Warning && d.Message.Contains("pull/400") && d.Message.Contains("could not be fetched")); + Collector.Diagnostics + .Should() + .Contain(d => d.Severity == Severity.Warning && d.Message.Contains("pull/400") && d.Message.Contains("could not be fetched")); } [Fact] @@ -213,17 +223,12 @@ public async Task StartRefWithoutEndRef_Errors() { var service = Service(PoolHandler(), RangeService()); - var input = new BundleChangelogsArguments - { - Repo = "widget", - StartGitRef = StartRef - }; + var input = new BundleChangelogsArguments { Repo = "widget", StartGitRef = StartRef }; var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); result.Should().BeFalse(); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && d.Message.Contains("must be provided together")); + Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("must be provided together")); } [Fact] @@ -242,8 +247,9 @@ public async Task GitRefCombinedWithOtherFilter_Errors() var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); result.Should().BeFalse(); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && d.Message.Contains("cannot be combined with other filter sources")); + Collector.Diagnostics + .Should() + .Contain(d => d.Severity == Severity.Error && d.Message.Contains("cannot be combined with other filter sources")); } [Fact] @@ -253,8 +259,7 @@ public async Task ProfileWithProductsPattern_Errors() FileSystem.Directory.CreateDirectory(outputDir); // language=yaml - var configContent = - """ + var configContent = """ bundle: output_directory: PLACEHOLDER repo: widget @@ -262,7 +267,10 @@ public async Task ProfileWithProductsPattern_Errors() profiles: filtered: products: "cloud-hosted {version} *" - """.Replace("PLACEHOLDER", outputDir); + """.Replace( + "PLACEHOLDER", + outputDir + ); var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); @@ -281,8 +289,7 @@ public async Task ProfileWithProductsPattern_Errors() var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); result.Should().BeFalse(); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && d.Message.Contains("products pattern")); + Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("products pattern")); } [Fact] @@ -293,8 +300,8 @@ public async Task InferredEntry_NoTypeLabel_DefaultsToOtherWithWarning() var configPath = await WriteProfileConfig(outputDir); var prService = A.Fake(); - _ = A.CallTo(() => prService.FetchPrInfoAsync(A._, A._, A._, A._)) - .Returns(new GitHubPrInfo + _ = + A.CallTo(() => prService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns(new GitHubPrInfo { Title = "Unlabeled change", Body = "No release note block here.", @@ -315,15 +322,16 @@ public async Task InferredEntry_NoTypeLabel_DefaultsToOtherWithWarning() var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); - result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + result.Should().BeTrue( + $"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}" + ); var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); outputFiles.Should().ContainSingle(); var bundle = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); bundle.Should().Contain("Unlabeled change"); bundle.Should().Contain("type: other"); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Warning && d.Message.Contains("defaulting to 'other'")); + Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Warning && d.Message.Contains("defaulting to 'other'")); } [Fact] @@ -365,9 +373,26 @@ public void GitRangeReport_ToMarkdown_ListsPrSourcesAndOrphanCommits() TotalCommits = 3, Rows = [ - new GitRangePrReportRow { Number = 100, Url = "https://github.com/elastic/widget/pull/100", Source = GitRangePrSourceKind.Pool, EntryFileNames = ["100.yaml"] }, - new GitRangePrReportRow { Number = 300, Url = "https://github.com/elastic/widget/pull/300", Source = GitRangePrSourceKind.InferredPrBody, EntryFileNames = ["300.yaml"] }, - new GitRangePrReportRow { Number = 400, Url = "https://github.com/elastic/widget/pull/400", Source = GitRangePrSourceKind.Missing } + new GitRangePrReportRow + { + Number = 100, + Url = "https://github.com/elastic/widget/pull/100", + Source = GitRangePrSourceKind.Pool, + EntryFileNames = ["100.yaml"] + }, + new GitRangePrReportRow + { + Number = 300, + Url = "https://github.com/elastic/widget/pull/300", + Source = GitRangePrSourceKind.InferredPrBody, + EntryFileNames = ["300.yaml"] + }, + new GitRangePrReportRow + { + Number = 400, + Url = "https://github.com/elastic/widget/pull/400", + Source = GitRangePrSourceKind.Missing + } ], CommitsWithoutPullRequest = ["deadbeef"] }; diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs index 15ad68ba77..72e95e3736 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs @@ -162,10 +162,7 @@ public void ResolveEntries_WithInlineEntries_ReturnsEntries() // Arrange var bundle = new Bundle { - Products = - [ - new BundledProduct { ProductId = "elasticsearch", Target = "9.3.0" } - ], + Products = [new BundledProduct { ProductId = "elasticsearch", Target = "9.3.0" }], Entries = [ new BundledEntry { Title = "Test feature", Type = ChangelogEntryType.Feature }, @@ -189,10 +186,7 @@ public void ResolveEntries_WithEntryLackingInlineContent_EmitsWarningNamingBundl // Arrange - a reference-style entry (file block only, no inline content) is invalid var bundle = new Bundle { - Products = - [ - new BundledProduct { ProductId = "elasticsearch", Target = "9.3.0" } - ], + Products = [new BundledProduct { ProductId = "elasticsearch", Target = "9.3.0" }], Entries = [ new BundledEntry { Title = "Inline feature", Type = ChangelogEntryType.Feature }, @@ -246,10 +240,7 @@ public void FilterEntries_WithPublishBlocker_HidesBlockedTypes() new() { Title = "Bug fix", Type = ChangelogEntryType.BugFix } }; - var publishBlocker = new PublishBlocker - { - Types = ["regression"] - }; + var publishBlocker = new PublishBlocker { Types = ["regression"] }; // Act var filtered = service.FilterEntries(entries, publishBlocker); @@ -271,10 +262,7 @@ public void FilterEntries_WithPublishBlocker_HidesBlockedAreas() new() { Title = "Mixed feature", Type = ChangelogEntryType.Feature, Areas = ["Search", "Internal"] } }; - var publishBlocker = new PublishBlocker - { - Areas = ["Internal"] - }; + var publishBlocker = new PublishBlocker { Areas = ["Internal"] }; // Act var filtered = service.FilterEntries(entries, publishBlocker); @@ -296,11 +284,7 @@ public void FilterEntries_WithPublishBlocker_CombinesTypeAndAreaBlocking() new() { Title = "Hidden by area", Type = ChangelogEntryType.Feature, Areas = ["Internal"] } }; - var publishBlocker = new PublishBlocker - { - Types = ["regression"], - Areas = ["Internal"] - }; + var publishBlocker = new PublishBlocker { Types = ["regression"], Areas = ["Internal"] }; // Act var filtered = service.FilterEntries(entries, publishBlocker); @@ -321,8 +305,14 @@ public void MergeBundlesByTarget_WithSingleBundle_ReturnsSameBundle() var service = CreateService(); var bundles = new List { - new("9.3.0", "elasticsearch", "elastic", new Bundle(), "/path/to/bundle.yaml", - [new ChangelogEntry { Title = "Entry 1", Type = ChangelogEntryType.Feature }]) + new( + "9.3.0", + "elasticsearch", + "elastic", + new Bundle(), + "/path/to/bundle.yaml", + [new ChangelogEntry { Title = "Entry 1", Type = ChangelogEntryType.Feature }] + ) }; // Act @@ -340,10 +330,22 @@ public void MergeBundlesByTarget_WithDifferentVersions_KeepsSeparate() var service = CreateService(); var bundles = new List { - new("9.3.0", "elasticsearch", "elastic", new Bundle(), "/path/to/9.3.0.yaml", - [new ChangelogEntry { Title = "Entry 9.3.0", Type = ChangelogEntryType.Feature }]), - new("9.2.0", "elasticsearch", "elastic", new Bundle(), "/path/to/9.2.0.yaml", - [new ChangelogEntry { Title = "Entry 9.2.0", Type = ChangelogEntryType.Feature }]) + new( + "9.3.0", + "elasticsearch", + "elastic", + new Bundle(), + "/path/to/9.3.0.yaml", + [new ChangelogEntry { Title = "Entry 9.3.0", Type = ChangelogEntryType.Feature }] + ), + new( + "9.2.0", + "elasticsearch", + "elastic", + new Bundle(), + "/path/to/9.2.0.yaml", + [new ChangelogEntry { Title = "Entry 9.2.0", Type = ChangelogEntryType.Feature }] + ) }; // Act @@ -360,10 +362,22 @@ public void MergeBundlesByTarget_WithSameVersion_MergesEntries() var service = CreateService(); var bundles = new List { - new("9.3.0", "elasticsearch", "elastic", new Bundle(), "/path/to/es.yaml", - [new ChangelogEntry { Title = "ES Entry", Type = ChangelogEntryType.Feature }]), - new("9.3.0", "kibana", "elastic", new Bundle(), "/path/to/kibana.yaml", - [new ChangelogEntry { Title = "Kibana Entry", Type = ChangelogEntryType.Feature }]) + new( + "9.3.0", + "elasticsearch", + "elastic", + new Bundle(), + "/path/to/es.yaml", + [new ChangelogEntry { Title = "ES Entry", Type = ChangelogEntryType.Feature }] + ), + new( + "9.3.0", + "kibana", + "elastic", + new Bundle(), + "/path/to/kibana.yaml", + [new ChangelogEntry { Title = "Kibana Entry", Type = ChangelogEntryType.Feature }] + ) }; // Act @@ -1024,10 +1038,7 @@ public void LoadBundles_HideFeaturesSerializesAndDeserializesCorrectly() var originalBundle = new Bundle { - Products = - [ - new BundledProduct { ProductId = "elasticsearch", Target = "9.3.0" } - ], + Products = [new BundledProduct { ProductId = "elasticsearch", Target = "9.3.0" }], HideFeatures = ["feature:first", "feature:second", "feature:third"], Entries = [ @@ -1063,7 +1074,8 @@ public void LoadBundles_DescriptionSerializesAndDeserializesCorrectly() var bundlesFolder = "/docs/changelog/bundles"; _fileSystem.Directory.CreateDirectory(bundlesFolder); - var multilineDescription = """ + var multilineDescription = + """ This is a test description with multiple paragraphs. It includes: @@ -1076,10 +1088,7 @@ This ensures proper YAML serialization and deserialization. var originalBundle = new Bundle { - Products = - [ - new BundledProduct { ProductId = "elasticsearch", Target = "9.3.0" } - ], + Products = [new BundledProduct { ProductId = "elasticsearch", Target = "9.3.0" }], Description = multilineDescription, Entries = [ @@ -1120,10 +1129,7 @@ public void LoadBundles_DescriptionCanBeNull() var originalBundle = new Bundle { - Products = - [ - new BundledProduct { ProductId = "elasticsearch", Target = "9.3.0" } - ], + Products = [new BundledProduct { ProductId = "elasticsearch", Target = "9.3.0" }], Description = null, Entries = [ @@ -1159,10 +1165,7 @@ public void LoadBundles_ReleaseDateSerializesAndDeserializesCorrectly() var originalBundle = new Bundle { - Products = - [ - new BundledProduct { ProductId = "apm-agent-dotnet", Target = "1.34.0" } - ], + Products = [new BundledProduct { ProductId = "apm-agent-dotnet", Target = "1.34.0" }], ReleaseDate = new DateOnly(2026, 4, 9), Entries = [ @@ -1197,10 +1200,7 @@ public void LoadBundles_ReleaseDateCanBeNull() var originalBundle = new Bundle { - Products = - [ - new BundledProduct { ProductId = "elasticsearch", Target = "9.3.0" } - ], + Products = [new BundledProduct { ProductId = "elasticsearch", Target = "9.3.0" }], ReleaseDate = null, Entries = [ @@ -1343,12 +1343,7 @@ public void MergeBundlesByTarget_ReleaseDatePreserved() public void LoadedBundle_HideFeatures_ExposedFromBundleData() { // Arrange - Verify that LoadedBundle.HideFeatures properly exposes Data.HideFeatures - var bundleData = new Bundle - { - Products = [], - HideFeatures = ["feature:a", "feature:b"], - Entries = [] - }; + var bundleData = new Bundle { Products = [], HideFeatures = ["feature:a", "feature:b"], Entries = [] }; var entries = new List(); var bundle = new LoadedBundle("9.3.0", "elasticsearch", "elastic", bundleData, "/path/to/bundle.yaml", entries); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs index bab8772ab3..2b0894f43a 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs @@ -14,8 +14,7 @@ public class BundlePlanTests : ChangelogTestBase { private ChangelogBundlingService Service { get; } - public BundlePlanTests(ITestOutputHelper output) : base(output) => - Service = new(LoggerFactory, FileSystem, ConfigurationContext); + public BundlePlanTests(ITestOutputHelper output) : base(output) => Service = new(LoggerFactory, FileSystem, ConfigurationContext); private async Task CreateConfigAsync(string configContent) { @@ -70,12 +69,7 @@ public async Task Plan_ProfileMode_RepoResolvable_ReturnsNeedsNetwork() """; var configPath = await CreateConfigAsync(configContent); - var input = new BundleChangelogsArguments - { - Profile = "my-profile", - ProfileArgument = "9.2.0", - Config = configPath - }; + var input = new BundleChangelogsArguments { Profile = "my-profile", ProfileArgument = "9.2.0", Config = configPath }; var result = await Service.PlanBundleAsync(Collector, input, hasReleaseVersion: false, TestContext.Current.CancellationToken); @@ -105,12 +99,7 @@ public async Task Plan_ProfileMode_NoRepo_ReturnsNoNetwork() """; var configPath = await CreateConfigAsync(configContent); - var input = new BundleChangelogsArguments - { - Profile = "my-profile", - ProfileArgument = "9.2.0", - Config = configPath - }; + var input = new BundleChangelogsArguments { Profile = "my-profile", ProfileArgument = "9.2.0", Config = configPath }; var result = await Service.PlanBundleAsync(Collector, input, hasReleaseVersion: false, TestContext.Current.CancellationToken); @@ -136,12 +125,7 @@ public async Task Plan_ProfileMode_OutputProductsScopeCdnUrl() """; var configPath = await CreateConfigAsync(configContent); - var input = new BundleChangelogsArguments - { - Profile = "serverless", - ProfileArgument = "2026-03", - Config = configPath - }; + var input = new BundleChangelogsArguments { Profile = "serverless", ProfileArgument = "2026-03", Config = configPath }; var result = await Service.PlanBundleAsync(Collector, input, hasReleaseVersion: false, TestContext.Current.CancellationToken); @@ -166,12 +150,7 @@ public async Task Plan_ProfileMode_UseLocalChangelogs_ReturnsNoNetwork() """; var configPath = await CreateConfigAsync(configContent); - var input = new BundleChangelogsArguments - { - Profile = "my-profile", - ProfileArgument = "9.2.0", - Config = configPath - }; + var input = new BundleChangelogsArguments { Profile = "my-profile", ProfileArgument = "9.2.0", Config = configPath }; var result = await Service.PlanBundleAsync(Collector, input, hasReleaseVersion: false, TestContext.Current.CancellationToken); @@ -196,12 +175,7 @@ public async Task Plan_ProfileMode_GitHubRelease_ReturnsNeedsNetwork() """; var configPath = await CreateConfigAsync(configContent); - var input = new BundleChangelogsArguments - { - Profile = "es-release", - ProfileArgument = "v9.2.0", - Config = configPath - }; + var input = new BundleChangelogsArguments { Profile = "es-release", ProfileArgument = "v9.2.0", Config = configPath }; var result = await Service.PlanBundleAsync(Collector, input, hasReleaseVersion: false, TestContext.Current.CancellationToken); @@ -227,25 +201,21 @@ public async Task Plan_ProfileMode_LifecycleSubstitution_ResolvesCorrectly() """; var configPath = await CreateConfigAsync(configContent); - var input = new BundleChangelogsArguments - { - Profile = "dotnet-release", - ProfileArgument = "1.0.0-beta.1", - Config = configPath - }; + var input = new BundleChangelogsArguments { Profile = "dotnet-release", ProfileArgument = "1.0.0-beta.1", Config = configPath }; var result = await Service.PlanBundleAsync(Collector, input, hasReleaseVersion: false, TestContext.Current.CancellationToken); result.Should().NotBeNull(); - result.OutputPath.Should().EndWith(FileSystem.Path.Join("docs", "releases", "dotnet-1.0.0-beta.1-beta.yaml").OptionalWindowsReplace()); + result.OutputPath + .Should() + .EndWith(FileSystem.Path.Join("docs", "releases", "dotnet-1.0.0-beta.1-beta.yaml").OptionalWindowsReplace()); } [Fact] public async Task Plan_NoOutput_FallsBackToConfigOutputDirectory() { // language=yaml - var configContent = - """ + var configContent = """ bundle: output_directory: docs/releases """; @@ -272,17 +242,11 @@ public async Task Plan_ProfileNotFound_ReturnsResultWithNeedsNetworkFalse() """; var configPath = await CreateConfigAsync(configContent); - var input = new BundleChangelogsArguments - { - Profile = "nonexistent-profile", - ProfileArgument = "9.2.0", - Config = configPath - }; + var input = new BundleChangelogsArguments { Profile = "nonexistent-profile", ProfileArgument = "9.2.0", Config = configPath }; var result = await Service.PlanBundleAsync(Collector, input, hasReleaseVersion: false, TestContext.Current.CancellationToken); result.Should().NotBeNull(); result.NeedsNetwork.Should().BeFalse(); } - } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs index 72347cffa2..92d64e4144 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs @@ -44,8 +44,7 @@ public async Task ProfileGitHubRelease_BundlesMatchingChangelogs() // that match changelogs already in the input directory. // language=yaml - var configContent = - """ + var configContent = """ bundle: directory: PLACEHOLDER use_local_changelogs: true @@ -56,7 +55,10 @@ public async Task ProfileGitHubRelease_BundlesMatchingChangelogs() repo: elasticsearch output: "elasticsearch-{version}.yaml" output_products: "elasticsearch {version} {lifecycle}" - """.Replace("PLACEHOLDER", _changelogDir); + """.Replace( + "PLACEHOLDER", + _changelogDir + ); var configPath = await CreateConfigAsync(configContent); @@ -99,8 +101,9 @@ public async Task ProfileGitHubRelease_BundlesMatchingChangelogs() * Second feature by @user2 in https://github.com/elastic/elasticsearch/pull/200 """; - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "9.2.0", TestContext.Current.CancellationToken)) - .Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "9.2.0", TestContext.Current.CancellationToken) + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); @@ -134,8 +137,7 @@ public async Task ProfileGitHubRelease_AutoInfersVersionAndLifecycle_FromRelease // use the clean version "9.2.0" and inferred lifecycle "ga". // language=yaml - var configContent = - """ + var configContent = """ bundle: directory: PLACEHOLDER use_local_changelogs: true @@ -146,7 +148,10 @@ public async Task ProfileGitHubRelease_AutoInfersVersionAndLifecycle_FromRelease repo: elasticsearch output: "elasticsearch-{version}.yaml" output_products: "elasticsearch {version} {lifecycle}" - """.Replace("PLACEHOLDER", _changelogDir); + """.Replace( + "PLACEHOLDER", + _changelogDir + ); var configPath = await CreateConfigAsync(configContent); @@ -169,8 +174,9 @@ public async Task ProfileGitHubRelease_AutoInfersVersionAndLifecycle_FromRelease var releaseBody = "* Some feature by @user in https://github.com/elastic/elasticsearch/pull/100\n"; // Return a tag with a "v" prefix to verify that ExtractBaseVersion strips it - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "9.2.0", TestContext.Current.CancellationToken)) - .Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "9.2.0", TestContext.Current.CancellationToken) + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); @@ -207,8 +213,7 @@ public async Task ProfileGitHubRelease_WithNoMatchingPrs_EmitsWarning() // Arrange — release notes contain no PR references; expect a warning and no bundle. // language=yaml - var configContent = - """ + var configContent = """ bundle: directory: PLACEHOLDER use_local_changelogs: true @@ -218,31 +223,29 @@ public async Task ProfileGitHubRelease_WithNoMatchingPrs_EmitsWarning() source: github_release repo: elasticsearch output: "elasticsearch-{version}.yaml" - """.Replace("PLACEHOLDER", _changelogDir); + """.Replace( + "PLACEHOLDER", + _changelogDir + ); var configPath = await CreateConfigAsync(configContent); var releaseBody = "No pull requests in this release."; - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "9.2.0", TestContext.Current.CancellationToken)) - .Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "9.2.0", TestContext.Current.CancellationToken) + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); - var input = new BundleChangelogsArguments - { - Profile = "es-gh-release", - ProfileArgument = "9.2.0", - Config = configPath - }; + var input = new BundleChangelogsArguments { Profile = "es-gh-release", ProfileArgument = "9.2.0", Config = configPath }; // Act var result = await _service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); // Assert result.Should().BeFalse("Should fail when no PR references found in the release"); - Collector.Diagnostics.Should().Contain(d => - d.Message.Contains("no PR references found"), - "Should emit a warning about missing PR references" - ); + Collector.Diagnostics + .Should() + .Contain(d => d.Message.Contains("no PR references found"), "Should emit a warning about missing PR references"); } [Fact] @@ -264,15 +267,11 @@ public async Task ProfileGitHubRelease_FetchFailure_ReturnsError() var configPath = await CreateConfigAsync(configContent); - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "9.2.0", TestContext.Current.CancellationToken)) - .Returns((GitHubReleaseInfo?)null); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "9.2.0", TestContext.Current.CancellationToken) + ).Returns((GitHubReleaseInfo?)null); - var input = new BundleChangelogsArguments - { - Profile = "es-gh-release", - ProfileArgument = "9.2.0", - Config = configPath - }; + var input = new BundleChangelogsArguments { Profile = "es-gh-release", ProfileArgument = "9.2.0", Config = configPath }; // Act var result = await _service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -288,8 +287,7 @@ public async Task ProfileGitHubRelease_Latest_CallsFetchWithLatestTag() // Arrange — passing "latest" as the version should forward "latest" to FetchReleaseAsync. // language=yaml - var configContent = - """ + var configContent = """ bundle: directory: PLACEHOLDER use_local_changelogs: true @@ -300,7 +298,10 @@ public async Task ProfileGitHubRelease_Latest_CallsFetchWithLatestTag() repo: elasticsearch output: "elasticsearch-{version}.yaml" output_products: "elasticsearch {version} {lifecycle}" - """.Replace("PLACEHOLDER", _changelogDir); + """.Replace( + "PLACEHOLDER", + _changelogDir + ); var configPath = await CreateConfigAsync(configContent); @@ -322,8 +323,9 @@ public async Task ProfileGitHubRelease_Latest_CallsFetchWithLatestTag() var releaseBody = "* Latest feature by @user in https://github.com/elastic/elasticsearch/pull/999\n"; - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "latest", TestContext.Current.CancellationToken)) - .Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "latest", TestContext.Current.CancellationToken) + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); @@ -343,8 +345,9 @@ public async Task ProfileGitHubRelease_Latest_CallsFetchWithLatestTag() result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); Collector.Errors.Should().Be(0); - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "latest", TestContext.Current.CancellationToken)) - .MustHaveHappenedOnceExactly(); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "latest", TestContext.Current.CancellationToken) + ).MustHaveHappenedOnceExactly(); } [Fact] @@ -364,12 +367,7 @@ public async Task ProfileGitHubRelease_RequiresRepo_ReturnsError() var configPath = await CreateConfigAsync(configContent); - var input = new BundleChangelogsArguments - { - Profile = "es-gh-release", - ProfileArgument = "9.2.0", - Config = configPath - }; + var input = new BundleChangelogsArguments { Profile = "es-gh-release", ProfileArgument = "9.2.0", Config = configPath }; // Act var result = await _service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -377,10 +375,12 @@ public async Task ProfileGitHubRelease_RequiresRepo_ReturnsError() // Assert result.Should().BeFalse("Should fail when no repo is configured"); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && d.Message.Contains("requires a GitHub repository name"), - "Should emit an error about missing repo" - ); + Collector.Diagnostics + .Should() + .Contain( + d => d.Severity == Severity.Error && d.Message.Contains("requires a GitHub repository name"), + "Should emit an error about missing repo" + ); } [Fact] @@ -403,12 +403,7 @@ public async Task ProfileGitHubRelease_MutuallyExclusiveWithProducts_ReturnsErro var configPath = await CreateConfigAsync(configContent); - var input = new BundleChangelogsArguments - { - Profile = "es-gh-release", - ProfileArgument = "9.2.0", - Config = configPath - }; + var input = new BundleChangelogsArguments { Profile = "es-gh-release", ProfileArgument = "9.2.0", Config = configPath }; // Act var result = await _service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -416,10 +411,12 @@ public async Task ProfileGitHubRelease_MutuallyExclusiveWithProducts_ReturnsErro // Assert result.Should().BeFalse("Should fail when source and products are both configured"); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && d.Message.Contains("cannot be combined with a 'products' filter"), - "Should emit an error about the mutual exclusivity" - ); + Collector.Diagnostics + .Should() + .Contain( + d => d.Severity == Severity.Error && d.Message.Contains("cannot be combined with a 'products' filter"), + "Should emit an error about the mutual exclusivity" + ); } [Fact] @@ -455,10 +452,12 @@ public async Task ProfileGitHubRelease_MutuallyExclusiveWithPromotionReport_Retu // Assert result.Should().BeFalse("Should fail when profileReport is provided alongside source: github_release"); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && d.Message.Contains("does not accept a third positional argument"), - "Should emit an error about the mutual exclusivity with hint to hardcode lifecycle" - ); + Collector.Diagnostics + .Should() + .Contain( + d => d.Severity == Severity.Error && d.Message.Contains("does not accept a third positional argument"), + "Should emit an error about the mutual exclusivity with hint to hardcode lifecycle" + ); } [Fact] @@ -468,8 +467,7 @@ public async Task ProfileGitHubRelease_InfersBetaLifecycle_FromTagSuffix() // even though ExtractBaseVersion strips the suffix to produce version "9.2.0". // language=yaml - var configContent = - """ + var configContent = """ bundle: directory: PLACEHOLDER use_local_changelogs: true @@ -480,7 +478,10 @@ public async Task ProfileGitHubRelease_InfersBetaLifecycle_FromTagSuffix() repo: elasticsearch output: "elasticsearch-{version}.yaml" output_products: "elasticsearch {version} {lifecycle}" - """.Replace("PLACEHOLDER", _changelogDir); + """.Replace( + "PLACEHOLDER", + _changelogDir + ); var configPath = await CreateConfigAsync(configContent); @@ -502,8 +503,9 @@ public async Task ProfileGitHubRelease_InfersBetaLifecycle_FromTagSuffix() var releaseBody = "* Beta feature by @user in https://github.com/elastic/elasticsearch/pull/100\n"; - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "9.2.0-beta.1", TestContext.Current.CancellationToken)) - .Returns(new GitHubReleaseInfo { TagName = "v9.2.0-beta.1", Name = "9.2.0 beta 1", Body = releaseBody }); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "9.2.0-beta.1", TestContext.Current.CancellationToken) + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0-beta.1", Name = "9.2.0 beta 1", Body = releaseBody }); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); @@ -538,8 +540,7 @@ public async Task ProfileGitHubRelease_InfersPreviewLifecycle_FromTagSuffix() // Arrange — release tag is "v1.34.1-preview.1"; {lifecycle} should be "preview". // language=yaml - var configContent = - """ + var configContent = """ bundle: directory: PLACEHOLDER use_local_changelogs: true @@ -550,7 +551,10 @@ public async Task ProfileGitHubRelease_InfersPreviewLifecycle_FromTagSuffix() repo: apm-agent-dotnet output: "apm-agent-dotnet-{version}.yaml" output_products: "apm-agent-dotnet {version} {lifecycle}" - """.Replace("PLACEHOLDER", _changelogDir); + """.Replace( + "PLACEHOLDER", + _changelogDir + ); var configPath = await CreateConfigAsync(configContent); @@ -572,8 +576,15 @@ public async Task ProfileGitHubRelease_InfersPreviewLifecycle_FromTagSuffix() var releaseBody = "* Preview feature by @user in https://github.com/elastic/apm-agent-dotnet/pull/42\n"; - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "apm-agent-dotnet", "v1.34.1-preview.1", TestContext.Current.CancellationToken)) - .Returns(new GitHubReleaseInfo { TagName = "v1.34.1-preview.1", Name = "1.34.1 preview 1", Body = releaseBody }); + A.CallTo( + () => + _mockReleaseService.FetchReleaseAsync( + "elastic", + "apm-agent-dotnet", + "v1.34.1-preview.1", + TestContext.Current.CancellationToken + ) + ).Returns(new GitHubReleaseInfo { TagName = "v1.34.1-preview.1", Name = "1.34.1 preview 1", Body = releaseBody }); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); @@ -607,8 +618,7 @@ public async Task ProfileGitHubRelease_BundleLevelRepo_UsedWhenProfileOmitsRepo( // Arrange — no repo at profile level; bundle.repo should be used as the fallback. // language=yaml - var configContent = - """ + var configContent = """ bundle: directory: PLACEHOLDER use_local_changelogs: true @@ -619,7 +629,10 @@ public async Task ProfileGitHubRelease_BundleLevelRepo_UsedWhenProfileOmitsRepo( source: github_release output: "elasticsearch-{version}.yaml" output_products: "elasticsearch {version} {lifecycle}" - """.Replace("PLACEHOLDER", _changelogDir); + """.Replace( + "PLACEHOLDER", + _changelogDir + ); var configPath = await CreateConfigAsync(configContent); @@ -642,8 +655,9 @@ public async Task ProfileGitHubRelease_BundleLevelRepo_UsedWhenProfileOmitsRepo( var releaseBody = "* Some feature by @user in https://github.com/elastic/elasticsearch/pull/100\n"; // Expect the call to use bundle-level repo "elasticsearch" and owner "elastic" - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "9.2.0", TestContext.Current.CancellationToken)) - .Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "9.2.0", TestContext.Current.CancellationToken) + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); @@ -663,7 +677,8 @@ public async Task ProfileGitHubRelease_BundleLevelRepo_UsedWhenProfileOmitsRepo( result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); Collector.Errors.Should().Be(0); - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "9.2.0", TestContext.Current.CancellationToken)) - .MustHaveHappenedOnceExactly(); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "9.2.0", TestContext.Current.CancellationToken) + ).MustHaveHappenedOnceExactly(); } } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleReleaseVersionTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleReleaseVersionTests.cs index 557b5423b2..e920a72d87 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleReleaseVersionTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleReleaseVersionTests.cs @@ -42,7 +42,8 @@ private string BundleOutputPath() => public async Task ReleaseVersion_BundlesMatchingChangelogs() { // Arrange – two changelog files each referencing a specific PR - await WriteChangelog("pr-12345.yaml", + await WriteChangelog( + "pr-12345.yaml", """ title: Fix query parsing type: bug-fix @@ -52,9 +53,11 @@ await WriteChangelog("pr-12345.yaml", lifecycle: ga prs: - https://github.com/elastic/elasticsearch/pull/12345 - """); + """ + ); - await WriteChangelog("pr-12346.yaml", + await WriteChangelog( + "pr-12346.yaml", """ title: New aggregation API type: feature @@ -64,7 +67,8 @@ await WriteChangelog("pr-12346.yaml", lifecycle: ga prs: - https://github.com/elastic/elasticsearch/pull/12346 - """); + """ + ); // Release body in GitHub Default format referencing both PRs var releaseBody = @@ -77,17 +81,13 @@ await WriteChangelog("pr-12346.yaml", **Full Changelog**: https://github.com/elastic/elasticsearch/compare/v9.1.0...v9.2.0 """; - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._)) - .Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._) + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); // Act – simulate what the command does var prUrls = await ResolveReleasePrUrls("elastic", "elasticsearch", "v9.2.0"); - var input = new BundleChangelogsArguments - { - Directory = _changelogDir, - Prs = prUrls, - Output = BundleOutputPath() - }; + var input = new BundleChangelogsArguments { Directory = _changelogDir, Prs = prUrls, Output = BundleOutputPath() }; var result = await _bundlingService.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -108,7 +108,8 @@ await WriteChangelog("pr-12346.yaml", public async Task ReleaseVersion_ExplicitOutputProducts_SetsBundleProducts() { // Arrange - await WriteChangelog("pr-12345.yaml", + await WriteChangelog( + "pr-12345.yaml", """ title: Fix query parsing type: bug-fix @@ -118,17 +119,18 @@ await WriteChangelog("pr-12345.yaml", lifecycle: ga prs: - https://github.com/elastic/elasticsearch/pull/12345 - """); - - var releaseBody = """ + ); + + var releaseBody = """ ## What's Changed * Fix query parsing by @contributor1 in #12345 """; - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._)) - .Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._) + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); var prUrls = await ResolveReleasePrUrls("elastic", "elasticsearch", "v9.2.0"); @@ -162,16 +164,13 @@ await WriteChangelog("pr-12345.yaml", public async Task ReleaseVersion_WithNoMatchingPrs_EmitsWarning() { // Arrange - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._)) - .Returns(new GitHubReleaseInfo - { - TagName = "v9.2.0", - Name = "9.2.0", - Body = "Release notes with no pull request references." - }); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._) + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = "Release notes with no pull request references." }); // Act – replicate command logic: parse, detect zero refs, warn and return success - var release = await _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", TestContext.Current.CancellationToken); + var release = + await _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", TestContext.Current.CancellationToken); var parsed = ReleaseNoteParser.Parse(release!.Body); // Assert @@ -189,11 +188,13 @@ public async Task ReleaseVersion_WithNoMatchingPrs_EmitsWarning() public async Task ReleaseVersion_FetchFailure_ReturnsNull() { // Arrange - A.CallTo(() => _mockReleaseService.FetchReleaseAsync(A._, A._, A._, A._)) - .Returns((GitHubReleaseInfo?)null); + A.CallTo(() => _mockReleaseService.FetchReleaseAsync(A._, A._, A._, A._)).Returns( + (GitHubReleaseInfo?)null + ); // Act - var release = await _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", TestContext.Current.CancellationToken); + var release = + await _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", TestContext.Current.CancellationToken); // Assert – command returns error on null release release.Should().BeNull(); @@ -207,20 +208,17 @@ public async Task ReleaseVersion_FetchFailure_ReturnsNull() public async Task ReleaseVersion_Latest_CallsFetchWithLatestTag() { // Arrange - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "latest", A._)) - .Returns(new GitHubReleaseInfo - { - TagName = "v9.2.0", - Name = "9.2.0", - Body = "No PR references." - }); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "latest", A._) + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = "No PR references." }); // Act _ = await _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "latest", TestContext.Current.CancellationToken); // Assert - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "latest", A._)) - .MustHaveHappenedOnceExactly(); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "latest", A._) + ).MustHaveHappenedOnceExactly(); } // ----------------------------------------------------------------------- @@ -238,10 +236,6 @@ private async Task ResolveReleasePrUrls(string owner, string repo, str { var release = await _mockReleaseService.FetchReleaseAsync(owner, repo, version, TestContext.Current.CancellationToken); var parsed = ReleaseNoteParser.Parse(release!.Body); - return parsed.PrReferences - .Select(r => $"https://github.com/{owner}/{repo}/pull/{r.PrNumber}") - .ToArray(); + return parsed.PrReferences.Select(r => $"https://github.com/{owner}/{repo}/pull/{r.PrNumber}").ToArray(); } - } - diff --git a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogConfigurationTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogConfigurationTests.cs index f0b1af079f..1f6a3ead50 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogConfigurationTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogConfigurationTests.cs @@ -23,8 +23,7 @@ public async Task LoadChangelogConfiguration_WithoutPivot_UsesDefaults() var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); // Config without pivot - should use defaults // language=yaml - var configContent = - """ + var configContent = """ lifecycles: - ga """; @@ -282,10 +281,13 @@ public async Task LoadChangelogConfiguration_WithInvalidPivotType_ReturnsError() // Assert config.Should().BeNull(); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && - d.Message.Contains("Type 'invalid-type' in pivot.types") && - d.Message.Contains("is not a valid type")); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Severity == Severity.Error && d.Message.Contains("Type 'invalid-type' in pivot.types") && + d.Message.Contains("is not a valid type") + ); } finally { @@ -304,8 +306,7 @@ public async Task LoadChangelogConfiguration_WithMissingRequiredTypes_ReturnsErr var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); // Config missing required type 'breaking-change' // language=yaml - var configContent = - """ + var configContent = """ pivot: types: feature: @@ -324,9 +325,9 @@ public async Task LoadChangelogConfiguration_WithMissingRequiredTypes_ReturnsErr // Assert config.Should().BeNull(); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && - d.Message.Contains("Required type 'breaking-change' is missing")); + Collector.Diagnostics + .Should() + .Contain(d => d.Severity == Severity.Error && d.Message.Contains("Required type 'breaking-change' is missing")); } finally { @@ -369,10 +370,13 @@ public async Task LoadChangelogConfiguration_WithSubtypesOnNonBreakingChange_Ret // Assert config.Should().BeNull(); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && - d.Message.Contains("Type 'feature' has subtypes defined") && - d.Message.Contains("subtypes are only allowed for 'breaking-change' type")); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Severity == Severity.Error && d.Message.Contains("Type 'feature' has subtypes defined") && + d.Message.Contains("subtypes are only allowed for 'breaking-change' type") + ); } finally { @@ -459,10 +463,13 @@ public async Task LoadChangelogConfiguration_WithInvalidSubtype_ReturnsError() // Assert config.Should().BeNull(); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && - d.Message.Contains("Subtype 'invalid-subtype'") && - d.Message.Contains("is not a valid subtype")); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Severity == Severity.Error && d.Message.Contains("Subtype 'invalid-subtype'") && + d.Message.Contains("is not a valid subtype") + ); } finally { @@ -474,8 +481,9 @@ public async Task LoadChangelogConfiguration_WithInvalidSubtype_ReturnsError() public async Task LoadChangelogConfiguration_RulesCreateExclude_AsString_ParsesCorrectly() { // Arrange - rules.create.exclude as comma-separated string - var config = await LoadConfig( - """ + var config = + await LoadConfig( + """ pivot: types: feature: @@ -484,7 +492,8 @@ public async Task LoadChangelogConfiguration_RulesCreateExclude_AsString_ParsesC rules: create: exclude: ">non-issue, >test, >skip" - """); + """ + ); // Assert config.Should().NotBeNull(); @@ -499,8 +508,9 @@ public async Task LoadChangelogConfiguration_RulesCreateExclude_AsString_ParsesC public async Task LoadChangelogConfiguration_RulesCreateExclude_AsList_ParsesCorrectly() { // Arrange - rules.create.exclude as YAML list - var config = await LoadConfig( - """ + var config = + await LoadConfig( + """ pivot: types: feature: @@ -512,7 +522,8 @@ public async Task LoadChangelogConfiguration_RulesCreateExclude_AsList_ParsesCor - ">non-issue" - ">test" - ">skip" - """); + """ + ); // Assert config.Should().NotBeNull(); @@ -527,8 +538,9 @@ public async Task LoadChangelogConfiguration_RulesCreateExclude_AsList_ParsesCor public async Task LoadChangelogConfiguration_PublishExcludeTypes_AsString_IgnoredAndWarningEmitted() { // Arrange - rules.publish is deprecated and no longer used; verify warning is emitted and Publish is null - var config = await LoadConfig( - """ + var config = + await LoadConfig( + """ pivot: types: feature: @@ -537,24 +549,30 @@ public async Task LoadChangelogConfiguration_PublishExcludeTypes_AsString_Ignore rules: publish: exclude_types: "deprecation, known-issue" - """); + """ + ); // Assert config.Should().NotBeNull(); config.Rules.Should().NotBeNull(); - config.Rules.Publish.Should().BeNull(); // rules.publish is retired + config.Rules.Publish.Should().BeNull(); // rules.publish is retired Collector.Warnings.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Message.Contains("rules.publish is deprecated") && - d.Message.Contains("no longer used by the changelog render command")); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Message.Contains("rules.publish is deprecated") && + d.Message.Contains("no longer used by the changelog render command") + ); } [Fact] public async Task LoadChangelogConfiguration_PublishExcludeTypes_AsList_IgnoredAndWarningEmitted() { // Arrange - rules.publish as YAML list is deprecated - var config = await LoadConfig( - """ + var config = + await LoadConfig( + """ pivot: types: feature: @@ -565,13 +583,14 @@ public async Task LoadChangelogConfiguration_PublishExcludeTypes_AsList_IgnoredA exclude_types: - deprecation - known-issue - """); + """ + ); // Assert config.Should().NotBeNull(); Collector.Errors.Should().Be(0); config.Rules.Should().NotBeNull(); - config.Rules.Publish.Should().BeNull(); // rules.publish is retired + config.Rules.Publish.Should().BeNull(); // rules.publish is retired Collector.Warnings.Should().BeGreaterThan(0); } @@ -579,8 +598,9 @@ public async Task LoadChangelogConfiguration_PublishExcludeTypes_AsList_IgnoredA public async Task LoadChangelogConfiguration_PublishExcludeAreas_AsString_IgnoredAndWarningEmitted() { // Arrange - rules.publish with areas is deprecated - var config = await LoadConfig( - """ + var config = + await LoadConfig( + """ pivot: types: feature: @@ -589,13 +609,14 @@ public async Task LoadChangelogConfiguration_PublishExcludeAreas_AsString_Ignore rules: publish: exclude_areas: "Internal, Experimental" - """); + """ + ); // Assert config.Should().NotBeNull(); Collector.Errors.Should().Be(0); config.Rules.Should().NotBeNull(); - config.Rules.Publish.Should().BeNull(); // rules.publish is retired + config.Rules.Publish.Should().BeNull(); // rules.publish is retired Collector.Warnings.Should().BeGreaterThan(0); } @@ -603,8 +624,9 @@ public async Task LoadChangelogConfiguration_PublishExcludeAreas_AsString_Ignore public async Task LoadChangelogConfiguration_PublishExcludeAreas_AsList_IgnoredAndWarningEmitted() { // Arrange - rules.publish as YAML list is deprecated - var config = await LoadConfig( - """ + var config = + await LoadConfig( + """ pivot: types: feature: @@ -615,13 +637,14 @@ public async Task LoadChangelogConfiguration_PublishExcludeAreas_AsList_IgnoredA exclude_areas: - Internal - Experimental - """); + """ + ); // Assert config.Should().NotBeNull(); Collector.Errors.Should().Be(0); config.Rules.Should().NotBeNull(); - config.Rules.Publish.Should().BeNull(); // rules.publish is retired + config.Rules.Publish.Should().BeNull(); // rules.publish is retired Collector.Warnings.Should().BeGreaterThan(0); } @@ -629,15 +652,17 @@ public async Task LoadChangelogConfiguration_PublishExcludeAreas_AsList_IgnoredA public async Task LoadChangelogConfiguration_PivotHighlight_AsString_ParsesCorrectly() { // Arrange - pivot.highlight as comma-separated string - var config = await LoadConfig( - """ + var config = + await LoadConfig( + """ pivot: types: feature: bug-fix: breaking-change: highlight: ">highlight, >release-highlight" - """); + """ + ); // Assert config.Should().NotBeNull(); @@ -649,8 +674,9 @@ public async Task LoadChangelogConfiguration_PivotHighlight_AsString_ParsesCorre public async Task LoadChangelogConfiguration_PivotHighlight_AsList_ParsesCorrectly() { // Arrange - pivot.highlight as YAML list - var config = await LoadConfig( - """ + var config = + await LoadConfig( + """ pivot: types: feature: @@ -659,7 +685,8 @@ public async Task LoadChangelogConfiguration_PivotHighlight_AsList_ParsesCorrect highlight: - ">highlight" - ">release-highlight" - """); + """ + ); // Assert config.Should().NotBeNull(); @@ -671,8 +698,9 @@ public async Task LoadChangelogConfiguration_PivotHighlight_AsList_ParsesCorrect public async Task LoadChangelogConfiguration_PivotAreas_AsListValues_ComputesMapping() { // Arrange - pivot.areas with list values instead of comma-separated strings - var config = await LoadConfig( - """ + var config = + await LoadConfig( + """ pivot: types: feature: @@ -683,7 +711,8 @@ public async Task LoadChangelogConfiguration_PivotAreas_AsListValues_ComputesMap - ":Search/Search" - ":Search/Ranking" Security: ":Security/Security" - """); + """ + ); // Assert config.Should().NotBeNull(); @@ -706,8 +735,9 @@ public async Task LoadChangelogConfiguration_PivotAreas_AsListValues_ComputesMap public async Task LoadChangelogConfiguration_TypeLabels_AsList_ComputesMapping() { // Arrange - pivot.types labels as YAML list instead of comma-separated string - var config = await LoadConfig( - """ + var config = + await LoadConfig( + """ pivot: types: feature: @@ -719,7 +749,8 @@ public async Task LoadChangelogConfiguration_TypeLabels_AsList_ComputesMapping() labels: - ">breaking" - ">bc" - """); + """ + ); // Assert config.Should().NotBeNull(); @@ -741,8 +772,9 @@ public async Task LoadChangelogConfiguration_TypeLabels_AsList_ComputesMapping() public async Task LoadChangelogConfiguration_SubtypeLabels_AsList_ParsesCorrectly() { // Arrange - breaking-change subtype labels as YAML list - var config = await LoadConfig( - """ + var config = + await LoadConfig( + """ pivot: types: feature: @@ -754,7 +786,8 @@ public async Task LoadChangelogConfiguration_SubtypeLabels_AsList_ParsesCorrectl - ">api-breaking" - ">api-change" behavioral: ">behavioral-breaking" - """); + """ + ); // Assert config.Should().NotBeNull(); @@ -774,8 +807,9 @@ public async Task LoadChangelogConfiguration_SubtypeLabels_AsList_ParsesCorrectl public async Task LoadChangelogConfiguration_ProductCreateExclude_AsList_ParsesCorrectly() { // Arrange - product-specific rules.create.products.*.exclude as YAML list - var config = await LoadConfig( - """ + var config = + await LoadConfig( + """ pivot: types: feature: @@ -788,7 +822,8 @@ public async Task LoadChangelogConfiguration_ProductCreateExclude_AsList_ParsesC exclude: - ">test" - ">skip" - """); + """ + ); // Assert config.Should().NotBeNull(); @@ -805,8 +840,9 @@ public async Task LoadChangelogConfiguration_ProductCreateExclude_AsList_ParsesC public async Task LoadChangelogConfiguration_MixedStringAndListForms_ParsesCorrectly() { // Arrange - mix of string and list forms in the same config - var config = await LoadConfig( - """ + var config = + await LoadConfig( + """ pivot: types: feature: @@ -833,7 +869,8 @@ public async Task LoadChangelogConfiguration_MixedStringAndListForms_ParsesCorre exclude_types: "deprecation, known-issue" exclude_areas: - Internal - """); + """ + ); // Assert config.Should().NotBeNull(); @@ -846,7 +883,7 @@ public async Task LoadChangelogConfiguration_MixedStringAndListForms_ParsesCorre config.Rules.Create.Mode.Should().Be(FieldMode.Exclude); // publish.exclude_types as string, publish.exclude_areas as list are deprecated - config.Rules.Publish.Should().BeNull(); // rules.publish is retired + config.Rules.Publish.Should().BeNull(); // rules.publish is retired // highlight as list config.HighlightLabels.Should().BeEquivalentTo([">highlight"]); @@ -906,7 +943,8 @@ public async Task LoadChangelogConfiguration_BundleSection_ParsesRepoOwnerDirect FileSystem.Directory.CreateDirectory(docsDir); var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); // language=yaml - await FileSystem.File.WriteAllTextAsync(configPath, + await FileSystem.File.WriteAllTextAsync( + configPath, """ bundle: repo: apm-agent-dotnet @@ -947,7 +985,8 @@ public async Task LoadChangelogConfiguration_BundleSectionAbsent_BundleIsNull() FileSystem.Directory.CreateDirectory(docsDir); var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); // language=yaml - await FileSystem.File.WriteAllTextAsync(configPath, + await FileSystem.File.WriteAllTextAsync( + configPath, """ lifecycles: - ga @@ -1009,7 +1048,8 @@ public async Task LoadChangelogConfiguration_BundleSection_ParsesReleaseDates() FileSystem.Directory.CreateDirectory(docsDir); var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); // language=yaml - await FileSystem.File.WriteAllTextAsync(configPath, + await FileSystem.File.WriteAllTextAsync( + configPath, """ bundle: release_dates: false @@ -1060,7 +1100,8 @@ public async Task LoadChangelogConfiguration_BundleSection_ReleaseDatesDefaultsT FileSystem.Directory.CreateDirectory(docsDir); var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); // language=yaml - await FileSystem.File.WriteAllTextAsync(configPath, + await FileSystem.File.WriteAllTextAsync( + configPath, """ bundle: repo: test-repo @@ -1365,10 +1406,12 @@ public async Task LoadChangelogConfiguration_WithRulesBundle_BothExcludeAndInclu [InlineData("timestamp", FilenameStrategy.Timestamp)] public async Task LoadChangelogConfiguration_Filename_ParsesStrategy(string yamlValue, FilenameStrategy expected) { - var config = await LoadConfig( - $""" + var config = + await LoadConfig( + $""" filename: {yamlValue} - """); + """ + ); config.Should().NotBeNull(); Collector.Errors.Should().Be(0); @@ -1378,8 +1421,7 @@ public async Task LoadChangelogConfiguration_Filename_ParsesStrategy(string yaml [Fact] public async Task LoadChangelogConfiguration_Filename_Missing_DefaultsToTimestamp() { - var config = await LoadConfig( - """ + var config = await LoadConfig(""" lifecycles: - ga """); @@ -1392,16 +1434,15 @@ public async Task LoadChangelogConfiguration_Filename_Missing_DefaultsToTimestam [Fact] public async Task LoadChangelogConfiguration_Filename_Invalid_ReturnsError() { - var config = await LoadConfig( - """ + var config = await LoadConfig(""" filename: random-value """); config.Should().BeNull(); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && - d.Message.Contains("filename: 'random-value' is not valid")); + Collector.Diagnostics + .Should() + .Contain(d => d.Severity == Severity.Error && d.Message.Contains("filename: 'random-value' is not valid")); } [Fact] @@ -1412,8 +1453,7 @@ public async Task LoadChangelogConfiguration_WithRulesBundle_UnknownProductId_Re var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); // language=yaml - var configContent = - """ + var configContent = """ rules: bundle: exclude_products: not-a-real-product @@ -1426,7 +1466,9 @@ public async Task LoadChangelogConfiguration_WithRulesBundle_UnknownProductId_Re // Assert config.Should().BeNull(); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => d.Message.Contains("'not-a-real-product'") && d.Message.Contains("not in the list of available products")); + Collector.Diagnostics + .Should() + .Contain(d => d.Message.Contains("'not-a-real-product'") && d.Message.Contains("not in the list of available products")); } [Fact] @@ -1437,8 +1479,7 @@ public async Task LoadChangelogConfiguration_WithRulesPublish_EmitsDeprecationWa var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); // language=yaml - var configContent = - """ + var configContent = """ rules: publish: exclude_types: docs @@ -1506,8 +1547,7 @@ public async Task LoadChangelogConfiguration_WithRulesBundle_TypeAreaAndProducts public async Task LoadChangelogConfiguration_ExtractStripTitlePrefix_True_LoadsCorrectly() { // Arrange - var config = await LoadConfig( - """ + var config = await LoadConfig(""" extract: strip_title_prefix: true """); @@ -1523,8 +1563,7 @@ public async Task LoadChangelogConfiguration_ExtractStripTitlePrefix_True_LoadsC public async Task LoadChangelogConfiguration_ExtractStripTitlePrefix_False_LoadsCorrectly() { // Arrange - var config = await LoadConfig( - """ + var config = await LoadConfig(""" extract: strip_title_prefix: false """); @@ -1540,8 +1579,7 @@ public async Task LoadChangelogConfiguration_ExtractStripTitlePrefix_False_Loads public async Task LoadChangelogConfiguration_ExtractStripTitlePrefix_Missing_DefaultsFalse() { // Arrange - var config = await LoadConfig( - """ + var config = await LoadConfig(""" lifecycles: - ga """); @@ -1557,13 +1595,15 @@ public async Task LoadChangelogConfiguration_ExtractStripTitlePrefix_Missing_Def public async Task LoadChangelogConfiguration_ExtractStripTitlePrefix_WithOtherExtractSettings_LoadsCorrectly() { // Arrange - var config = await LoadConfig( - """ + var config = + await LoadConfig( + """ extract: release_notes: false issues: true strip_title_prefix: true - """); + """ + ); // Act & Assert config.Should().NotBeNull(); @@ -1758,8 +1798,7 @@ public async Task LoadChangelogConfiguration_UseLocalChangelogs_DefaultsToFalse( FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); // language=yaml - var configContent = - """ + var configContent = """ bundle: directory: docs/changelog """; @@ -1781,8 +1820,7 @@ public async Task LoadChangelogConfiguration_BundleResolve_Deprecated_IgnoredAnd FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); // language=yaml - var configContent = - """ + var configContent = """ bundle: directory: docs/changelog resolve: true @@ -1797,9 +1835,9 @@ public async Task LoadChangelogConfiguration_BundleResolve_Deprecated_IgnoredAnd Collector.Errors.Should().Be(0); config.Bundle.Should().NotBeNull(); Collector.Warnings.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Warning && - d.Message.Contains("bundle.resolve is deprecated and ignored")); + Collector.Diagnostics + .Should() + .Contain(d => d.Severity == Severity.Warning && d.Message.Contains("bundle.resolve is deprecated and ignored")); } [Fact] diff --git a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs index 27ebb30c73..3f1c7ea37a 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs @@ -90,8 +90,7 @@ private async Task WriteFile(string fileName, string content) await FileSystem.File.WriteAllTextAsync(path, content, TestContext.Current.CancellationToken); } - private bool FileExists(string fileName) => - FileSystem.File.Exists(FileSystem.Path.Join(_changelogDir, fileName)); + private bool FileExists(string fileName) => FileSystem.File.Exists(FileSystem.Path.Join(_changelogDir, fileName)); // ------------------------------------------------------------------ // Basic filter tests @@ -207,9 +206,7 @@ public async Task Remove_WithNoFilter_EmitsError() var result = await Service.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); result.Should().BeFalse(); - Collector.Diagnostics.Should().ContainSingle(d => - d.Severity == Severity.Error && - d.Message.Contains("At least one filter option")); + Collector.Diagnostics.Should().ContainSingle(d => d.Severity == Severity.Error && d.Message.Contains("At least one filter option")); } [Fact] @@ -227,9 +224,9 @@ public async Task Remove_WithMultipleFilters_EmitsError() var result = await Service.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); result.Should().BeFalse(); - Collector.Diagnostics.Should().ContainSingle(d => - d.Severity == Severity.Error && - d.Message.Contains("Multiple filter options cannot be specified together")); + Collector.Diagnostics + .Should() + .ContainSingle(d => d.Severity == Severity.Error && d.Message.Contains("Multiple filter options cannot be specified together")); } [Fact] @@ -246,9 +243,9 @@ public async Task Remove_WithNoMatchingChangelogs_EmitsError() var result = await Service.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); result.Should().BeFalse(); - Collector.Diagnostics.Should().ContainSingle(d => - d.Severity == Severity.Error && - d.Message.Contains("No changelog entries matched")); + Collector.Diagnostics + .Should() + .ContainSingle(d => d.Severity == Severity.Error && d.Message.Contains("No changelog entries matched")); } // ------------------------------------------------------------------ @@ -329,7 +326,9 @@ public async Task Remove_WithProfileAndVersion_DeletesMatchingProducts() var result = await ServiceWithConfig.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); - result.Should().BeTrue($"Expected removal to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + result.Should().BeTrue( + $"Expected removal to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}" + ); Collector.Errors.Should().Be(0); // 9.2.0 file removed FileExists("5001-es-920-feature.yaml").Should().BeFalse("Profile-matched file should be removed"); @@ -372,7 +371,9 @@ public async Task Remove_WithProfileAndPromotionReport_DeletesMatchingPrs() var result = await ServiceWithConfig.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); - result.Should().BeTrue($"Expected removal to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + result.Should().BeTrue( + $"Expected removal to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}" + ); Collector.Errors.Should().Be(0); FileExists("1001-es-feature.yaml").Should().BeFalse("PR-matched file should be removed"); FileExists("2001-kibana-feature.yaml").Should().BeTrue("Non-matched file should remain"); @@ -407,10 +408,11 @@ public async Task Remove_WithProfile_UnknownProfile_ReturnsError() var result = await ServiceWithConfig.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); result.Should().BeFalse(); - Collector.Diagnostics.Should().ContainSingle(d => - d.Severity == Severity.Error && - d.Message.Contains("nonexistent-profile") && - d.Message.Contains("not found")); + Collector.Diagnostics + .Should() + .ContainSingle( + d => d.Severity == Severity.Error && d.Message.Contains("nonexistent-profile") && d.Message.Contains("not found") + ); } [Fact] @@ -442,10 +444,11 @@ public async Task Remove_WithProfile_MissingProfileArg_ReturnsError() var result = await ServiceWithConfig.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); result.Should().BeFalse(); - Collector.Diagnostics.Should().ContainSingle(d => - d.Severity == Severity.Error && - d.Message.Contains("es-release") && - d.Message.Contains("requires a version number")); + Collector.Diagnostics + .Should() + .ContainSingle( + d => d.Severity == Severity.Error && d.Message.Contains("es-release") && d.Message.Contains("requires a version number") + ); } [Fact] @@ -453,10 +456,7 @@ public async Task Remove_WithProfileMode_MissingConfig_ReturnsErrorWithAdvice() { // Arrange - no config file exists at ./changelog.yml or ./docs/changelog.yml. // Use a fresh MockFileSystem with a known CWD so discovery returns no results. - var cwdFs = new System.IO.Abstractions.TestingHelpers.MockFileSystem( - null, - currentDirectory: "/empty-project" - ); + var cwdFs = new System.IO.Abstractions.TestingHelpers.MockFileSystem(null, currentDirectory: "/empty-project"); cwdFs.Directory.CreateDirectory("/empty-project"); var service = new ChangelogRemoveService(LoggerFactory, ChangelogFileSystem.FromWorkingDirectory(cwdFs), ConfigurationContext); @@ -472,11 +472,12 @@ public async Task Remove_WithProfileMode_MissingConfig_ReturnsErrorWithAdvice() // Assert result.Should().BeFalse("Should fail when no config file is found"); - Collector.Diagnostics.Should().ContainSingle(d => - d.Severity == Severity.Error && - (d.Message.Contains("changelog.yml") || d.Message.Contains("changelog init")), - "Error message should mention changelog.yml or advise running changelog init" - ); + Collector.Diagnostics + .Should() + .ContainSingle( + d => d.Severity == Severity.Error && (d.Message.Contains("changelog.yml") || d.Message.Contains("changelog init")), + "Error message should mention changelog.yml or advise running changelog init" + ); } [Fact] @@ -510,10 +511,12 @@ public async Task Remove_WithProfile_NoProductsAndVersionArg_ReturnsSpecificErro var result = await ServiceWithConfig.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); result.Should().BeFalse(); - Collector.Diagnostics.Should().ContainSingle(d => - d.Severity == Severity.Error && - d.Message.Contains("no-products-profile") && - d.Message.Contains("no 'products' pattern")); + Collector.Diagnostics + .Should() + .ContainSingle( + d => + d.Severity == Severity.Error && d.Message.Contains("no-products-profile") && d.Message.Contains("no 'products' pattern") + ); } // ─── Phase 3: URL list file support for remove ────────────────────────────────── @@ -525,8 +528,7 @@ public async Task Remove_WithProfile_UrlListFile_PrUrls_RemovesMatchedFiles() await WriteFile("2001-kibana-feature.yaml", KibanaFeatureYaml); // language=yaml - var configContent = - """ + var configContent = """ bundle: profiles: release: @@ -560,7 +562,10 @@ await FileSystem.File.WriteAllTextAsync( Collector.Errors.Should().Be(0); // Dry-run: files still exist but the matched one should have been identified - FileSystem.File.Exists(FileSystem.Path.Join(_changelogDir, "1001-es-feature.yaml")).Should().BeTrue("dry-run should not delete files"); + FileSystem.File + .Exists(FileSystem.Path.Join(_changelogDir, "1001-es-feature.yaml")) + .Should() + .BeTrue("dry-run should not delete files"); } [Fact] @@ -570,8 +575,7 @@ public async Task Remove_WithProfile_CombinedVersionAndReport_UsesReportForFilte await WriteFile("2001-kibana-feature.yaml", KibanaFeatureYaml); // language=yaml - var configContent = - """ + var configContent = """ bundle: profiles: release: @@ -593,8 +597,10 @@ await FileSystem.File.WriteAllTextAsync( { Directory = _changelogDir, Profile = "release", - ProfileArgument = "9.3.0", // version string - ProfileReport = urlFile, // URL list file (Phase 3.4) + ProfileArgument = "9.3.0", // version string + + ProfileReport = urlFile, // URL list file (Phase 3.4) + Config = configPath, DryRun = true }; @@ -623,12 +629,7 @@ public async Task Remove_WithReportOption_ParsesPromotionReportAndFilters() FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(reportFile)!); await FileSystem.File.WriteAllTextAsync(reportFile, htmlReport, TestContext.Current.CancellationToken); - var input = new ChangelogRemoveArguments - { - Directory = _changelogDir, - Report = reportFile, - DryRun = true - }; + var input = new ChangelogRemoveArguments { Directory = _changelogDir, Report = reportFile, DryRun = true }; var result = await Service.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -660,8 +661,7 @@ public async Task Remove_WithBundleOwnerConfig_UsesConfigOwnerWhenOptionNotSpeci await WriteFile("pr-42.yaml", changelogContent); // language=yaml - var configContent = - """ + var configContent = """ bundle: owner: myorg repo: myrepo @@ -708,8 +708,7 @@ public async Task Remove_WithBundleRepoConfig_UsesConfigRepoWhenOptionNotSpecifi await WriteFile("pr-55.yaml", changelogContent); // language=yaml - var configContent = - """ + var configContent = """ bundle: repo: myrepo owner: myorg @@ -743,8 +742,7 @@ public async Task Remove_WithBundleOwnerConfig_CliOwnerTakesPrecedence() await WriteFile("pr-100.yaml", ElasticsearchFeatureYaml); // language=yaml - var configContent = - """ + var configContent = """ bundle: owner: config-org repo: elasticsearch @@ -760,7 +758,8 @@ public async Task Remove_WithBundleOwnerConfig_CliOwnerTakesPrecedence() Directory = _changelogDir, // Matching by PR URL with the explicit elastic org (CLI override) Prs = ["https://github.com/elastic/elasticsearch/pull/1001"], - Owner = "elastic", // CLI --owner overrides config-org + Owner = "elastic", // CLI --owner overrides config-org + Config = configPath }; @@ -782,11 +781,7 @@ public async Task Remove_WithFiles_DeletesOnlyNamedFiles() await WriteFile("2001-kibana-feature.yaml", KibanaFeatureYaml); var keepPath = FileSystem.Path.Join(_changelogDir, "1001-es-feature.yaml"); - var input = new ChangelogRemoveArguments - { - Directory = _changelogDir, - Files = [keepPath] - }; + var input = new ChangelogRemoveArguments { Directory = _changelogDir, Files = [keepPath] }; var result = await Service.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -801,7 +796,8 @@ public async Task Remove_WithProfile_PathListFile_RemovesListedFiles() await WriteFile("1001-es-feature.yaml", ElasticsearchFeatureYaml); await WriteFile("2001-kibana-feature.yaml", KibanaFeatureYaml); - var configContent = $""" + var configContent = + $""" bundle: directory: {_changelogDir} profiles: diff --git a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRepoOwnerResolverTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRepoOwnerResolverTests.cs index e87693d824..ac10c89083 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRepoOwnerResolverTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRepoOwnerResolverTests.cs @@ -44,8 +44,7 @@ public void NormalizeRepo_CombinedRepo_StripsOwnerPrefix() => ChangelogRepoOwnerResolver.NormalizeRepo("acme-corp/widget").Should().Be("widget"); [Fact] - public void NormalizeRepo_BareRepo_ReturnsUnchanged() => - ChangelogRepoOwnerResolver.NormalizeRepo("widget").Should().Be("widget"); + public void NormalizeRepo_BareRepo_ReturnsUnchanged() => ChangelogRepoOwnerResolver.NormalizeRepo("widget").Should().Be("widget"); [Fact] public void NormalizeRepo_NullOrEmpty_ReturnsUnchanged() diff --git a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogTestBase.cs b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogTestBase.cs index 94d406e1e2..afc77e0d08 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogTestBase.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogTestBase.cs @@ -32,9 +32,8 @@ protected ChangelogTestBase(ITestOutputHelper output) Output = output; var mockFileSystem = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); FileSystem = ChangelogFileSystem.FromWorkingDirectory(mockFileSystem); - RunnerTempFileSystem = new RunnerTempFileSystem( - mockFileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), - inner: mockFileSystem); + RunnerTempFileSystem = + new RunnerTempFileSystem(mockFileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: mockFileSystem); // ConfigurationFileProvider writes to AppData/config-runtime, which is outside ChangelogFileSystem's // git-root scope by design. Use a CheckoutsFileSystem (includes AppData) for the config provider only; // it wraps the same mock so both filesystems share in-memory state. @@ -47,7 +46,8 @@ protected ChangelogTestBase(ITestOutputHelper output) VersioningSystems = new Dictionary { { - VersioningSystemId.Stack, new VersioningSystem + VersioningSystemId.Stack, + new VersioningSystem { Id = VersioningSystemId.Stack, Current = new SemVersion(9, 2, 0), @@ -59,7 +59,8 @@ protected ChangelogTestBase(ITestOutputHelper output) var products = new Dictionary { { - "elasticsearch", new Product + "elasticsearch", + new Product { Id = "elasticsearch", DisplayName = "Elasticsearch", @@ -67,7 +68,8 @@ protected ChangelogTestBase(ITestOutputHelper output) } }, { - "kibana", new Product + "kibana", + new Product { Id = "kibana", DisplayName = "Kibana", @@ -75,7 +77,8 @@ protected ChangelogTestBase(ITestOutputHelper output) } }, { - "cloud-hosted", new Product + "cloud-hosted", + new Product { Id = "cloud-hosted", DisplayName = "Elastic Cloud Hosted", @@ -83,7 +86,8 @@ protected ChangelogTestBase(ITestOutputHelper output) } }, { - "cloud-serverless", new Product + "cloud-serverless", + new Product { Id = "cloud-serverless", DisplayName = "Elastic Cloud Serverless", @@ -91,7 +95,8 @@ protected ChangelogTestBase(ITestOutputHelper output) } }, { - "security", new Product + "security", + new Product { Id = "security", DisplayName = "Elastic Security", @@ -108,10 +113,7 @@ protected ChangelogTestBase(ITestOutputHelper output) ConfigurationContext = new ConfigurationContext { - Endpoints = new DocumentationEndpoints - { - Elasticsearch = ElasticsearchEndpoint.Default, - }, + Endpoints = new DocumentationEndpoints { Elasticsearch = ElasticsearchEndpoint.Default, }, ConfigurationFileProvider = new ConfigurationFileProvider(NullLoggerFactory.Instance, configFileSystem), VersionsConfiguration = versionsConfiguration, ProductsConfiguration = productsConfiguration, @@ -139,8 +141,7 @@ public void Dispose() GC.SuppressFinalize(this); } - [SuppressMessage("Security", "CA5350:Do not use insecure cryptographic algorithm SHA1", - Justification = "SHA1 is required for compatibility with existing changelog bundle format")] + [SuppressMessage("Security", "CA5350:Do not use insecure cryptographic algorithm SHA1", Justification = "SHA1 is required for compatibility with existing changelog bundle format")] protected static string ComputeSha1(string content) { var normalized = Documentation.Configuration.ReleaseNotes.ReleaseNotesSerialization.NormalizeYaml(content); @@ -157,12 +158,13 @@ protected static string ComputeSha1(string content) protected static string CreateResolvedBundleContent(string bundleHeaderYaml, params (string FileName, string Changelog)[] changelogs) { var bundle = Documentation.Configuration.ReleaseNotes.ReleaseNotesSerialization.DeserializeBundle(bundleHeaderYaml); - var entries = changelogs - .Select(c => Documentation.Configuration.ReleaseNotes.ReleaseNotesSerialization.DeserializeEntry(c.Changelog).ToBundledEntry() with - { - File = new Documentation.ReleaseNotes.BundledFile { Name = c.FileName, Checksum = ComputeSha1(c.Changelog) } - }) - .ToList(); + var entries = changelogs.Select( + c => + Documentation.Configuration.ReleaseNotes.ReleaseNotesSerialization.DeserializeEntry(c.Changelog).ToBundledEntry() with + { + File = new Documentation.ReleaseNotes.BundledFile { Name = c.FileName, Checksum = ComputeSha1(c.Changelog) } + } + ).ToList(); return Documentation.Configuration.ReleaseNotes.ReleaseNotesSerialization.SerializeBundle(bundle with { Entries = entries }); } } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs index 32294e20b7..3ac3d04445 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs @@ -32,7 +32,8 @@ public class CloudProfileFixtureTests(ITestOutputHelper output) : ChangelogTestB // A feature entry whose only PR points at an allowlisted public repo (kept on scrub) plus a PR to a // non-allowlisted repo (scrubbed away). // language=yaml - private const string FeatureEntry = """ + private const string FeatureEntry = + """ title: Faster hosted search type: feature products: @@ -46,7 +47,8 @@ public class CloudProfileFixtureTests(ITestOutputHelper output) : ChangelogTestB // A docs entry that matches the product/target filter but must be dropped by rules.bundle.exclude_types. // language=yaml - private const string DocsEntry = """ + private const string DocsEntry = + """ title: Tidy up the hosted docs type: docs products: @@ -59,7 +61,8 @@ public class CloudProfileFixtureTests(ITestOutputHelper output) : ChangelogTestB // A feature for a different product; excluded by the profile's cloud-hosted product filter. // language=yaml - private const string OtherProductEntry = """ + private const string OtherProductEntry = + """ title: Serverless-only change type: feature products: @@ -74,19 +77,20 @@ public class CloudProfileFixtureTests(ITestOutputHelper output) : ChangelogTestB private const string RegistryJson = """{ "schema_version": 1, "product": "widget", "bundles": [ { "file": "1-feature.yaml" }, { "file": "2-docs.yaml" }, { "file": "3-other.yaml" } ] }"""; - private StubHandler RepoPoolHandler() => new(req => - { - var path = req.RequestUri!.AbsolutePath; - if (path.EndsWith("/registry.json", StringComparison.Ordinal)) - return Json(RegistryJson); - if (path.EndsWith("1-feature.yaml", StringComparison.Ordinal)) - return Yaml(FeatureEntry); - if (path.EndsWith("2-docs.yaml", StringComparison.Ordinal)) - return Yaml(DocsEntry); - if (path.EndsWith("3-other.yaml", StringComparison.Ordinal)) - return Yaml(OtherProductEntry); - return new HttpResponseMessage(HttpStatusCode.NotFound); - }); + private StubHandler RepoPoolHandler() => + new(req => + { + var path = req.RequestUri!.AbsolutePath; + if (path.EndsWith("/registry.json", StringComparison.Ordinal)) + return Json(RegistryJson); + if (path.EndsWith("1-feature.yaml", StringComparison.Ordinal)) + return Yaml(FeatureEntry); + if (path.EndsWith("2-docs.yaml", StringComparison.Ordinal)) + return Yaml(DocsEntry); + if (path.EndsWith("3-other.yaml", StringComparison.Ordinal)) + return Yaml(OtherProductEntry); + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); private CdnChangelogEntryFetcher Fetcher(StubHandler handler) => new(new TestLoggerFactory(Output), handler, sleep: (_, _) => Task.CompletedTask); @@ -98,8 +102,7 @@ public async Task MonthlyProfile_SourcesFromRepoPool_ProducesSoundBundle() FileSystem.Directory.CreateDirectory(outputDir); // language=yaml - var configContent = - """ + var configContent = """ products: available: - cloud-hosted @@ -126,7 +129,10 @@ public async Task MonthlyProfile_SourcesFromRepoPool_ProducesSoundBundle() products: "cloud-hosted {version}-* *" output: "widget-{version}.yaml" output_products: "cloud-hosted {version}" - """.Replace("PLACEHOLDER", outputDir); + """.Replace( + "PLACEHOLDER", + outputDir + ); var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); @@ -135,16 +141,13 @@ public async Task MonthlyProfile_SourcesFromRepoPool_ProducesSoundBundle() var handler = RepoPoolHandler(); var service = new ChangelogBundlingService(LoggerFactory, FileSystem, ConfigurationContext, null, Fetcher(handler)); - var input = new BundleChangelogsArguments - { - Profile = "wh-monthly", - ProfileArgument = "2026-05", - Config = configPath - }; + var input = new BundleChangelogsArguments { Profile = "wh-monthly", ProfileArgument = "2026-05", Config = configPath }; var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); - result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + result.Should().BeTrue( + $"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}" + ); Collector.Errors.Should().Be(0); // Entries are sourced once from the authoring pool (changelog/{org}/{repo}/{branch}/...), not from @@ -167,8 +170,10 @@ public async Task MonthlyProfile_SourcesFromRepoPool_ProducesSoundBundle() // Link allowlist: the allowlisted public PR is kept verbatim; the non-allowlisted repo reference is // rewritten to a "# PRIVATE:" sentinel rather than left as a live link. bundle.Should().Contain("- https://github.com/elastic/elasticsearch/pull/100"); - bundle.Should().Contain("# PRIVATE: https://github.com/elastic/widget-internal/pull/7", - "non-allowlisted PR links must be scrubbed to a PRIVATE sentinel in bundle output"); + bundle.Should().Contain( + "# PRIVATE: https://github.com/elastic/widget-internal/pull/7", + "non-allowlisted PR links must be scrubbed to a PRIVATE sentinel in bundle output" + ); // release_dates: false → no auto-populated release date. bundle.Should().NotContain("release_date"); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/AddReportOptionTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/AddReportOptionTests.cs index f5f74ca3ea..0fced81ba4 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/AddReportOptionTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/AddReportOptionTests.cs @@ -33,18 +33,12 @@ public async Task CreateChangelog_FromPromotionReportHtmlFile_CreatesOneYamlPerP var pr1 = new GitHubPrInfo { Title = "First from report", Labels = ["type:feature"] }; var pr2 = new GitHubPrInfo { Title = "Second from report", Labels = ["type:bug"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A.That.Contains("7001"), - null, - null, - A._)) - .Returns(pr1); - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A.That.Contains("7002"), - null, - null, - A._)) - .Returns(pr2); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A.That.Contains("7001"), null, null, A._)).Returns( + pr1 + ); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A.That.Contains("7002"), null, null, A._)).Returns( + pr2 + ); // language=yaml var configContent = diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/BlockingLabelTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/BlockingLabelTests.cs index c5be473172..be4547df58 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/BlockingLabelTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/BlockingLabelTests.cs @@ -15,18 +15,9 @@ public class BlockingLabelTests(ITestOutputHelper output) : CreateChangelogTestB public async Task CreateChangelog_WithBlockingLabel_SkipsChangelogCreation() { // Arrange - var prInfo = new GitHubPrInfo - { - Title = "PR with blocking label", - Labels = ["type:feature", "skip:releaseNotes"] - }; + var prInfo = new GitHubPrInfo { Title = "PR with blocking label", Labels = ["type:feature", "skip:releaseNotes"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A._, - A._, - A._, - A._)) - .Returns(prInfo); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns(prInfo); // language=yaml var configContent = @@ -64,7 +55,9 @@ public async Task CreateChangelog_WithBlockingLabel_SkipsChangelogCreation() // Assert result.Should().BeTrue(); // Should succeed but skip creating changelog Collector.Warnings.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => d.Message.Contains("Skipping changelog creation") && d.Message.Contains("skip:releaseNotes")); + Collector.Diagnostics + .Should() + .Contain(d => d.Message.Contains("Skipping changelog creation") && d.Message.Contains("skip:releaseNotes")); var outputDir = input.Output ?? FileSystem.Directory.GetCurrentDirectory(); if (!FileSystem.Directory.Exists(outputDir)) @@ -77,18 +70,9 @@ public async Task CreateChangelog_WithBlockingLabel_SkipsChangelogCreation() public async Task CreateChangelog_WithBlockingLabelForSpecificProduct_OnlyBlocksForThatProduct() { // Arrange - var prInfo = new GitHubPrInfo - { - Title = "PR with blocking label", - Labels = ["type:feature", "ILM"] - }; + var prInfo = new GitHubPrInfo { Title = "PR with blocking label", Labels = ["type:feature", "ILM"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A._, - A._, - A._, - A._)) - .Returns(prInfo); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns(prInfo); // language=yaml var configContent = @@ -143,18 +127,9 @@ public async Task CreateChangelog_WithBlockingLabelForSpecificProduct_OnlyBlocks public async Task CreateChangelog_WithCommaSeparatedProductIdsInAddBlockers_ExpandsCorrectly() { // Arrange - var prInfo = new GitHubPrInfo - { - Title = "PR with blocking label", - Labels = ["type:feature", ">non-issue"] - }; + var prInfo = new GitHubPrInfo { Title = "PR with blocking label", Labels = ["type:feature", ">non-issue"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A._, - A._, - A._, - A._)) - .Returns(prInfo); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns(prInfo); // language=yaml var configContent = @@ -216,12 +191,7 @@ public async Task CreateChangelog_WithLabelDerivedProduct_AppliesProductSpecific Labels = ["type:feature", ":stack/elasticsearch", "skip:releaseNotes"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A._, - A._, - A._, - A._)) - .Returns(prInfo); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns(prInfo); // language=yaml var configContent = @@ -260,7 +230,9 @@ public async Task CreateChangelog_WithLabelDerivedProduct_AppliesProductSpecific // Assert — product-specific rule should have blocked creation result.Should().BeTrue(); // Succeed but skip - Collector.Diagnostics.Should().Contain(d => d.Message.Contains("Skipping changelog creation") && d.Message.Contains("skip:releaseNotes")); + Collector.Diagnostics + .Should() + .Contain(d => d.Message.Contains("Skipping changelog creation") && d.Message.Contains("skip:releaseNotes")); var outputDir = input.Output; if (!FileSystem.Directory.Exists(outputDir)) diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/CreateChangelogTestBase.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/CreateChangelogTestBase.cs index b679f379df..9ea76ef7d2 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/CreateChangelogTestBase.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/CreateChangelogTestBase.cs @@ -26,6 +26,5 @@ protected async Task CreateConfigDirectory(string configContent) return configPath; } - protected string CreateOutputDirectory() => - FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + protected string CreateOutputDirectory() => FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/FlagsAndFeaturesTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/FlagsAndFeaturesTests.cs index 4c550a12e0..b4d28e466b 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/FlagsAndFeaturesTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/FlagsAndFeaturesTests.cs @@ -94,11 +94,7 @@ public async Task CreateChangelog_WithIssues_CreatesValidYaml() Title = "Fix multiple issues", Type = "bug-fix", Products = [new ProductArgument { Product = "elasticsearch", Target = "9.2.0" }], - Issues = - [ - "https://github.com/elastic/elasticsearch/issues/123", - "https://github.com/elastic/elasticsearch/issues/456" - ], + Issues = ["https://github.com/elastic/elasticsearch/issues/123", "https://github.com/elastic/elasticsearch/issues/456"], Output = CreateOutputDirectory() }; diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/LabelMappingTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/LabelMappingTests.cs index 10864275ca..98b385ec8c 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/LabelMappingTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/LabelMappingTests.cs @@ -15,18 +15,9 @@ public class LabelMappingTests(ITestOutputHelper output) : CreateChangelogTestBa public async Task CreateChangelog_WithPrOptionAndLabelMapping_MapsLabelsToType() { // Arrange - var prInfo = new GitHubPrInfo - { - Title = "Fix memory leak in search", - Labels = ["type:bug"] - }; + var prInfo = new GitHubPrInfo { Title = "Fix memory leak in search", Labels = ["type:bug"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A._, - A._, - A._, - A._)) - .Returns(prInfo); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns(prInfo); // language=yaml var configContent = @@ -89,10 +80,7 @@ public void MapLabelsToProducts_WithProductIdOnly_ParsesCorrectly() }; // Act - var result = PrInfoProcessor.MapLabelsToProducts( - [":stack/elasticsearch", ":stack/kibana", "unrelated-label"], - labelToProducts - ); + var result = PrInfoProcessor.MapLabelsToProducts([":stack/elasticsearch", ":stack/kibana", "unrelated-label"], labelToProducts); // Assert result.Should().HaveCount(2); @@ -104,10 +92,7 @@ public void MapLabelsToProducts_WithProductIdOnly_ParsesCorrectly() public void MapLabelsToProducts_WithProductAndTarget_ParsesCorrectly() { // Arrange - var labelToProducts = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - [":feature/new"] = "elasticsearch 9.2.0" - }; + var labelToProducts = new Dictionary(StringComparer.OrdinalIgnoreCase) { [":feature/new"] = "elasticsearch 9.2.0" }; // Act var result = PrInfoProcessor.MapLabelsToProducts([":feature/new"], labelToProducts); @@ -176,18 +161,9 @@ public void MapLabelsToProducts_WithNoMatchingLabels_ReturnsEmpty() public async Task CreateChangelog_WithLabelProductMapping_DerviesProductsFromLabels() { // Arrange - var prInfo = new GitHubPrInfo - { - Title = "Add new search feature", - Labels = ["type:feature", ":stack/elasticsearch"] - }; + var prInfo = new GitHubPrInfo { Title = "Add new search feature", Labels = ["type:feature", ":stack/elasticsearch"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A._, - A._, - A._, - A._)) - .Returns(prInfo); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns(prInfo); // language=yaml var configContent = @@ -250,12 +226,7 @@ public async Task CreateChangelog_WithLabelProductMapping_MultipleMatchingLabels Labels = ["type:feature", ":stack/elasticsearch", ":stack/kibana"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A._, - A._, - A._, - A._)) - .Returns(prInfo); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns(prInfo); // language=yaml var configContent = @@ -305,18 +276,9 @@ public async Task CreateChangelog_WithLabelProductMapping_MultipleMatchingLabels public async Task CreateChangelog_WithLabelProductMapping_ExplicitProductsOverrideLabels() { // Arrange — PR has label for elasticsearch but --products specifies kibana - var prInfo = new GitHubPrInfo - { - Title = "Fix something", - Labels = ["type:bug", ":stack/elasticsearch"] - }; + var prInfo = new GitHubPrInfo { Title = "Fix something", Labels = ["type:bug", ":stack/elasticsearch"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A._, - A._, - A._, - A._)) - .Returns(prInfo); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns(prInfo); // language=yaml var configContent = @@ -371,12 +333,7 @@ public async Task CreateChangelog_WithPrOptionAndAreaMapping_MapsLabelsToAreas() Labels = ["type:enhancement", "area:security", "area:search"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A._, - A._, - A._, - A._)) - .Returns(prInfo); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns(prInfo); // language=yaml var configContent = @@ -444,14 +401,11 @@ public void MapLabelsToAreas_WithAreaNameContainingCommas_PresservesFullName() }; // Act - var result = PrInfoProcessor.MapLabelsToAreas( - ["Team:Alerting Services", "Team:Search"], - labelToAreas - ); + var result = PrInfoProcessor.MapLabelsToAreas(["Team:Alerting Services", "Team:Search"], labelToAreas); // Assert result.Should().HaveCount(2); - result.Should().Contain("Alerting, connectors, and reporting"); // Not split + result.Should().Contain("Alerting, connectors, and reporting"); // Not split result.Should().Contain("Search"); } @@ -459,18 +413,9 @@ public void MapLabelsToAreas_WithAreaNameContainingCommas_PresservesFullName() public async Task CreateChangelog_WithAreaNameContainingCommas_PreservesAreaName() { // Arrange: Area name contains commas - var prInfo = new GitHubPrInfo - { - Title = "Fix alerting issues", - Labels = ["type:bug", "Team:Alerting Services"] - }; + var prInfo = new GitHubPrInfo { Title = "Fix alerting issues", Labels = ["type:bug", "Team:Alerting Services"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A._, - A._, - A._, - A._)) - .Returns(prInfo); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns(prInfo); // language=yaml var configContent = @@ -518,25 +463,16 @@ public async Task CreateChangelog_WithAreaNameContainingCommas_PreservesAreaName FileSystem.Directory.CreateDirectory(outputDir); var files = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); var yamlContent = await FileSystem.File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); - yamlContent.Should().Contain("- Alerting, connectors, and reporting"); // Full name, not split + yamlContent.Should().Contain("- Alerting, connectors, and reporting"); // Full name, not split } [Fact] public async Task CreateChangelog_WithOneLabelMappedToMultipleAreas_AddsAllAreas() { // Arrange: Same label under multiple areas - var prInfo = new GitHubPrInfo - { - Title = "Cross-area change", - Labels = ["type:enhancement", "Team:Search"] - }; + var prInfo = new GitHubPrInfo { Title = "Cross-area change", Labels = ["type:enhancement", "Team:Search"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A._, - A._, - A._, - A._)) - .Returns(prInfo); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns(prInfo); // language=yaml var configContent = @@ -591,10 +527,7 @@ public void MapLabelsToFeatureId_WithSingleMatch_ReturnsFeatureId() ["feature-flag:new-search-api"] = "feature:new-search-api" }; - var result = PrInfoProcessor.MapLabelsToFeatureId( - ["feature-flag:new-search-api", "type:feature"], - labelToFeatures, - Collector); + var result = PrInfoProcessor.MapLabelsToFeatureId(["feature-flag:new-search-api", "type:feature"], labelToFeatures, Collector); result.Should().Be("feature:new-search-api"); Collector.Warnings.Should().Be(0); @@ -612,7 +545,8 @@ public void MapLabelsToFeatureId_WithDuplicateMatchingLabels_ReturnsSameFeatureI var result = PrInfoProcessor.MapLabelsToFeatureId( ["feature-flag:new-search-api", ":Feature/NewSearchApi"], labelToFeatures, - Collector); + Collector + ); result.Should().Be("feature:new-search-api"); Collector.Warnings.Should().Be(0); @@ -627,15 +561,11 @@ public void MapLabelsToFeatureId_WithMultipleDistinctMatches_WarnsAndReturnsFirs ["feature-flag:bar"] = "feature:bar" }; - var result = PrInfoProcessor.MapLabelsToFeatureId( - ["feature-flag:foo", "feature-flag:bar"], - labelToFeatures, - Collector); + var result = PrInfoProcessor.MapLabelsToFeatureId(["feature-flag:foo", "feature-flag:bar"], labelToFeatures, Collector); result.Should().Be("feature:foo"); Collector.Warnings.Should().Be(1); - Collector.Diagnostics.Should().Contain(d => - d.Message.Contains("Multiple feature-id values matched")); + Collector.Diagnostics.Should().Contain(d => d.Message.Contains("Multiple feature-id values matched")); } [Fact] @@ -646,10 +576,7 @@ public void MapLabelsToFeatureId_WithNoMatchingLabels_ReturnsNull() ["feature-flag:new-search-api"] = "feature:new-search-api" }; - var result = PrInfoProcessor.MapLabelsToFeatureId( - ["type:feature", ">bug"], - labelToFeatures, - Collector); + var result = PrInfoProcessor.MapLabelsToFeatureId(["type:feature", ">bug"], labelToFeatures, Collector); result.Should().BeNull(); Collector.Warnings.Should().Be(0); @@ -658,18 +585,9 @@ public void MapLabelsToFeatureId_WithNoMatchingLabels_ReturnsNull() [Fact] public async Task CreateChangelog_WithLabelFeatureMapping_DerivesFeatureIdFromLabels() { - var prInfo = new GitHubPrInfo - { - Title = "Add new search API", - Labels = ["type:feature", "feature-flag:new-search-api"] - }; + var prInfo = new GitHubPrInfo { Title = "Add new search API", Labels = ["type:feature", "feature-flag:new-search-api"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A._, - A._, - A._, - A._)) - .Returns(prInfo); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns(prInfo); // language=yaml var configContent = @@ -715,18 +633,9 @@ public async Task CreateChangelog_WithLabelFeatureMapping_DerivesFeatureIdFromLa [Fact] public async Task CreateChangelog_WithExplicitFeatureId_IgnoresLabelMapping() { - var prInfo = new GitHubPrInfo - { - Title = "Add new search API", - Labels = ["type:feature", "feature-flag:new-search-api"] - }; + var prInfo = new GitHubPrInfo { Title = "Add new search API", Labels = ["type:feature", "feature-flag:new-search-api"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A._, - A._, - A._, - A._)) - .Returns(prInfo); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns(prInfo); // language=yaml var configContent = @@ -768,18 +677,9 @@ public async Task CreateChangelog_WithExplicitFeatureId_IgnoresLabelMapping() [Fact] public async Task CreateChangelog_WithMultipleFeatureLabelMatches_WarnsAndUsesFirst() { - var prInfo = new GitHubPrInfo - { - Title = "Cross-feature change", - Labels = ["type:feature", "feature-flag:foo", "feature-flag:bar"] - }; + var prInfo = new GitHubPrInfo { Title = "Cross-feature change", Labels = ["type:feature", "feature-flag:foo", "feature-flag:bar"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A._, - A._, - A._, - A._)) - .Returns(prInfo); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns(prInfo); // language=yaml var configContent = @@ -813,8 +713,7 @@ public async Task CreateChangelog_WithMultipleFeatureLabelMatches_WarnsAndUsesFi result.Should().BeTrue(); Collector.Errors.Should().Be(0); Collector.Warnings.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Message.Contains("Multiple feature-id values matched")); + Collector.Diagnostics.Should().Contain(d => d.Message.Contains("Multiple feature-id values matched")); var files = FileSystem.Directory.GetFiles(input.Output, "*.yaml"); var yamlContent = await FileSystem.File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); @@ -824,18 +723,9 @@ public async Task CreateChangelog_WithMultipleFeatureLabelMatches_WarnsAndUsesFi [Fact] public async Task CreateChangelog_WithNoMatchingFeatureLabels_OmitsFeatureId() { - var prInfo = new GitHubPrInfo - { - Title = "Unrelated feature", - Labels = ["type:feature"] - }; + var prInfo = new GitHubPrInfo { Title = "Unrelated feature", Labels = ["type:feature"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A._, - A._, - A._, - A._)) - .Returns(prInfo); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns(prInfo); // language=yaml var configContent = @@ -869,8 +759,6 @@ public async Task CreateChangelog_WithNoMatchingFeatureLabels_OmitsFeatureId() var files = FileSystem.Directory.GetFiles(input.Output, "*.yaml"); var yamlContent = await FileSystem.File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); - yamlContent.Split('\n') - .Should() - .NotContain(line => line.TrimStart().StartsWith("feature-id:", StringComparison.Ordinal)); + yamlContent.Split('\n').Should().NotContain(line => line.TrimStart().StartsWith("feature-id:", StringComparison.Ordinal)); } } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/PrFetchFailureTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/PrFetchFailureTests.cs index 87f3d7c506..a15fffc177 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/PrFetchFailureTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/PrFetchFailureTests.cs @@ -32,12 +32,9 @@ public async Task CreateChangelog_WithPrOptionAndTitleAndType_SkipsApiFetch() Collector.Errors.Should().Be(0); Collector.Warnings.Should().Be(0); - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A._, - A._, - A._, - A._)) - .MustNotHaveHappened(); + A.CallTo( + () => MockGitHubService.FetchPrInfoAsync(A._, A._, A._, A._) + ).MustNotHaveHappened(); var outputDir = input.Output ?? FileSystem.Directory.GetCurrentDirectory(); if (!FileSystem.Directory.Exists(outputDir)) @@ -56,12 +53,9 @@ public async Task CreateChangelog_WithPrOptionAndTitleAndType_SkipsApiFetch() public async Task CreateChangelog_WithPrOptionButPrFetchFails_WithoutTitleAndType_CreatesChangelogWithCommentedFields() { // Arrange - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A._, - A._, - A._, - A._)) - .Returns((GitHubPrInfo?)null); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns( + (GitHubPrInfo?)null + ); var service = CreateService(); @@ -106,12 +100,9 @@ public async Task CreateChangelog_WithPrOptionButPrFetchFails_WithoutTitleAndTyp public async Task CreateChangelog_WithMultiplePrsButPrFetchFails_GeneratesBasicChangelogs() { // Arrange - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A._, - A._, - A._, - A._)) - .Returns((GitHubPrInfo?)null); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns( + (GitHubPrInfo?)null + ); var service = CreateService(); @@ -151,19 +142,18 @@ public async Task CreateChangelog_WithMultiplePrsButPrFetchFails_GeneratesBasicC yamlContent.Should().Contain("type: bug-fix"); // Should reference at least one of the PRs (when filenames collide, the last one wins) yamlContent.Should().Contain("prs:"); - yamlContent.Should().MatchRegex(@"(https://github\.com/elastic/elasticsearch/pull/12345|https://github\.com/elastic/elasticsearch/pull/67890)"); + yamlContent.Should().MatchRegex( + @"(https://github\.com/elastic/elasticsearch/pull/12345|https://github\.com/elastic/elasticsearch/pull/67890)" + ); } [Fact] public async Task CreateChangelog_WithMultiplePrsFetchFails_EmitsAggregateWarningSummary() { // Arrange - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A._, - A._, - A._, - A._)) - .Returns((GitHubPrInfo?)null); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns( + (GitHubPrInfo?)null + ); var service = CreateService(); @@ -180,22 +170,21 @@ public async Task CreateChangelog_WithMultiplePrsFetchFails_EmitsAggregateWarnin // Assert: by default the bulk fetch failure is a single, loud summary warning (not an error). result.Should().BeTrue(); Collector.Errors.Should().Be(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Warning && - d.Message.Contains("2 of 2") && - d.Message.Contains("could not be fetched from GitHub")); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Severity == Severity.Warning && d.Message.Contains("2 of 2") && d.Message.Contains("could not be fetched from GitHub") + ); } [Fact] public async Task CreateChangelog_WithMultiplePrsFetchFailsAndStrictFetch_EmitsError() { // Arrange - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A._, - A._, - A._, - A._)) - .Returns((GitHubPrInfo?)null); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns( + (GitHubPrInfo?)null + ); var service = CreateService(); @@ -214,21 +203,16 @@ public async Task CreateChangelog_WithMultiplePrsFetchFailsAndStrictFetch_EmitsE // but the best-effort files are still written so they can be inspected. result.Should().BeTrue(); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && - d.Message.Contains("could not be fetched from GitHub")); + Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("could not be fetched from GitHub")); } [Fact] public async Task CreateChangelog_WithMultipleIssuesFetchFailsAndStrictFetch_EmitsError() { // Arrange - A.CallTo(() => MockGitHubService.FetchIssueInfoAsync( - A._, - A._, - A._, - A._)) - .Returns((GitHubIssueInfo?)null); + A.CallTo(() => MockGitHubService.FetchIssueInfoAsync(A._, A._, A._, A._)).Returns( + (GitHubIssueInfo?)null + ); var service = CreateService(); @@ -247,21 +231,16 @@ public async Task CreateChangelog_WithMultipleIssuesFetchFailsAndStrictFetch_Emi // (non-zero exit), but the best-effort files are still written so they can be inspected. result.Should().BeTrue(); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && - d.Message.Contains("could not be fetched from GitHub")); + Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("could not be fetched from GitHub")); } [Fact] public async Task CreateChangelog_WithSinglePrFetchFailsAndStrictFetch_EmitsError() { // Arrange - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A._, - A._, - A._, - A._)) - .Returns((GitHubPrInfo?)null); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns( + (GitHubPrInfo?)null + ); var service = CreateService(); @@ -279,8 +258,6 @@ public async Task CreateChangelog_WithSinglePrFetchFailsAndStrictFetch_EmitsErro // Assert result.Should().BeTrue(); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && - d.Message.Contains("--strict-fetch")); + Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("--strict-fetch")); } } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/PrIntegrationTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/PrIntegrationTests.cs index 2c1760c00d..45d657f2a0 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/PrIntegrationTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/PrIntegrationTests.cs @@ -16,18 +16,17 @@ public class PrIntegrationTests(ITestOutputHelper output) : CreateChangelogTestB public async Task CreateChangelog_WithPrOption_FetchesPrInfoAndDerivesTitle() { // Arrange - var prInfo = new GitHubPrInfo - { - Title = "Implement new aggregation API", - Labels = ["type:feature"] - }; - - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - "https://github.com/elastic/elasticsearch/pull/12345", - null, - null, - A._)) - .Returns(prInfo); + var prInfo = new GitHubPrInfo { Title = "Implement new aggregation API", Labels = ["type:feature"] }; + + A.CallTo( + () => + MockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._ + ) + ).Returns(prInfo); // language=yaml var configContent = @@ -61,12 +60,15 @@ public async Task CreateChangelog_WithPrOption_FetchesPrInfoAndDerivesTitle() result.Should().BeTrue(); Collector.Errors.Should().Be(0); - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - "https://github.com/elastic/elasticsearch/pull/12345", - null, - null, - A._)) - .MustHaveHappenedOnceExactly(); + A.CallTo( + () => + MockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._ + ) + ).MustHaveHappenedOnceExactly(); // Note: ChangelogService uses real FileSystem, so we need to check the actual file system var outputDir = input.Output ?? FileSystem.Directory.GetCurrentDirectory(); @@ -86,18 +88,17 @@ public async Task CreateChangelog_WithPrOption_FetchesPrInfoAndDerivesTitle() public async Task CreateChangelog_WithUsePrNumber_CreatesFileWithPrNumberAsFilename() { // Arrange - var prInfo = new GitHubPrInfo - { - Title = "Fix memory leak in search", - Labels = ["type:bug"] - }; - - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - "https://github.com/elastic/elasticsearch/pull/140034", - null, - null, - A._)) - .Returns(prInfo); + var prInfo = new GitHubPrInfo { Title = "Fix memory leak in search", Labels = ["type:bug"] }; + + A.CallTo( + () => + MockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/140034", + null, + null, + A._ + ) + ).Returns(prInfo); // language=yaml var configContent = @@ -153,30 +154,16 @@ public async Task CreateChangelog_WithUsePrNumber_CreatesFileWithPrNumberAsFilen public async Task CreateChangelog_WithMultiplePrsAndUsePrNumber_CreatesOneFilePerPrEachNamedByPrNumber() { // With --use-pr-number and multiple PRs, creates one changelog per PR, each named by its PR number (not one aggregated file) - var pr1Info = new GitHubPrInfo - { - Title = "First PR", - Labels = ["type:feature"] - }; - var pr2Info = new GitHubPrInfo - { - Title = "Second PR", - Labels = ["type:bug"] - }; + var pr1Info = new GitHubPrInfo { Title = "First PR", Labels = ["type:feature"] }; + var pr2Info = new GitHubPrInfo { Title = "Second PR", Labels = ["type:bug"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A.That.Contains("1234"), - null, - null, - A._)) - .Returns(pr1Info); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A.That.Contains("1234"), null, null, A._)).Returns( + pr1Info + ); - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A.That.Contains("5678"), - null, - null, - A._)) - .Returns(pr2Info); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A.That.Contains("5678"), null, null, A._)).Returns( + pr2Info + ); // language=yaml var configContent = @@ -223,18 +210,11 @@ public async Task CreateChangelog_WithMultiplePrsAndUsePrNumber_CreatesOneFilePe public async Task CreateChangelog_WithUseIssueNumberAndBothIssuesAndPrs_UseIssueNumberForFilename() { // When both --issues and --prs are specified, --use-issue-number should still determine the filename - var prInfo = new GitHubPrInfo - { - Title = "Release notes test", - Labels = ["type:feature"] - }; + var prInfo = new GitHubPrInfo { Title = "Release notes test", Labels = ["type:feature"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - "https://github.com/elastic/kibana/pull/250840", - null, - null, - A._)) - .Returns(prInfo); + A.CallTo( + () => MockGitHubService.FetchPrInfoAsync("https://github.com/elastic/kibana/pull/250840", null, null, A._) + ).Returns(prInfo); // language=yaml var configContent = @@ -296,42 +276,25 @@ public async Task CreateChangelog_WithPrNumberAndOwnerRepo_SkipsApiFetchWhenTitl result.Should().BeTrue(); Collector.Errors.Should().Be(0); - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A._, - A._, - A._, - A._)) - .MustNotHaveHappened(); + A.CallTo( + () => MockGitHubService.FetchPrInfoAsync(A._, A._, A._, A._) + ).MustNotHaveHappened(); } [Fact] public async Task CreateChangelog_WithMultiplePrs_CreatesOneFilePerPr() { // Arrange - var pr1Info = new GitHubPrInfo - { - Title = "First PR feature", - Labels = ["type:feature"] - }; - var pr2Info = new GitHubPrInfo - { - Title = "Second PR bug fix", - Labels = ["type:bug"] - }; + var pr1Info = new GitHubPrInfo { Title = "First PR feature", Labels = ["type:feature"] }; + var pr2Info = new GitHubPrInfo { Title = "Second PR bug fix", Labels = ["type:bug"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A.That.Contains("1234"), - null, - null, - A._)) - .Returns(pr1Info); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A.That.Contains("1234"), null, null, A._)).Returns( + pr1Info + ); - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A.That.Contains("5678"), - null, - null, - A._)) - .Returns(pr2Info); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A.That.Contains("5678"), null, null, A._)).Returns( + pr2Info + ); // language=yaml var configContent = @@ -384,42 +347,21 @@ public async Task CreateChangelog_WithMultiplePrs_CreatesOneFilePerPr() public async Task CreateChangelog_WithPrsFromFile_ProcessesAllPrsFromFile() { // Arrange - Simulate what ChangelogCommand does: read PRs from a file - var pr1Info = new GitHubPrInfo - { - Title = "First PR from file", - Labels = ["type:feature"] - }; - var pr2Info = new GitHubPrInfo - { - Title = "Second PR from file", - Labels = ["type:bug"] - }; - var pr3Info = new GitHubPrInfo - { - Title = "Third PR from file", - Labels = ["type:enhancement"] - }; + var pr1Info = new GitHubPrInfo { Title = "First PR from file", Labels = ["type:feature"] }; + var pr2Info = new GitHubPrInfo { Title = "Second PR from file", Labels = ["type:bug"] }; + var pr3Info = new GitHubPrInfo { Title = "Third PR from file", Labels = ["type:enhancement"] }; + + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A.That.Contains("1111"), null, null, A._)).Returns( + pr1Info + ); - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A.That.Contains("1111"), - null, - null, - A._)) - .Returns(pr1Info); - - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A.That.Contains("2222"), - null, - null, - A._)) - .Returns(pr2Info); - - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A.That.Contains("3333"), - null, - null, - A._)) - .Returns(pr3Info); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A.That.Contains("2222"), null, null, A._)).Returns( + pr2Info + ); + + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A.That.Contains("3333"), null, null, A._)).Returns( + pr3Info + ); var tempDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(tempDir); @@ -436,10 +378,7 @@ public async Task CreateChangelog_WithPrsFromFile_ProcessesAllPrsFromFile() // Read PRs from file (simulating ChangelogCommand behavior) var prsFromFile = await FileSystem.File.ReadAllLinesAsync(prsFile, TestContext.Current.CancellationToken); - var parsedPrs = prsFromFile - .Where(line => !string.IsNullOrWhiteSpace(line)) - .Select(line => line.Trim()) - .ToArray(); + var parsedPrs = prsFromFile.Where(line => !string.IsNullOrWhiteSpace(line)).Select(line => line.Trim()).ToArray(); // language=yaml var configContent = @@ -462,6 +401,7 @@ public async Task CreateChangelog_WithPrsFromFile_ProcessesAllPrsFromFile() var input = new CreateChangelogArguments { Prs = parsedPrs, // PRs read from file + Products = [new ProductArgument { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], Config = configPath, Output = CreateOutputDirectory() @@ -494,38 +434,23 @@ public async Task CreateChangelog_WithPrsFromFile_ProcessesAllPrsFromFile() public async Task CreateChangelog_WithMixedPrsFromFileAndCommaSeparated_ProcessesAllPrs() { // Arrange - Simulate ChangelogCommand handling both file paths and comma-separated PRs - var pr1Info = new GitHubPrInfo - { - Title = "PR from comma-separated", - Labels = ["type:feature"] - }; - var pr2Info = new GitHubPrInfo - { - Title = "PR from file", - Labels = ["type:bug"] - }; + var pr1Info = new GitHubPrInfo { Title = "PR from comma-separated", Labels = ["type:feature"] }; + var pr2Info = new GitHubPrInfo { Title = "PR from file", Labels = ["type:bug"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A.That.Contains("1111"), - null, - null, - A._)) - .Returns(pr1Info); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A.That.Contains("1111"), null, null, A._)).Returns( + pr1Info + ); - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A.That.Contains("2222"), - null, - null, - A._)) - .Returns(pr2Info); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A.That.Contains("2222"), null, null, A._)).Returns( + pr2Info + ); var tempDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(tempDir); // Create a file with PRs var prsFile = FileSystem.Path.Join(tempDir, "prs.txt"); - var prsFileContent = - """ + var prsFileContent = """ https://github.com/elastic/elasticsearch/pull/2222 """; await FileSystem.File.WriteAllTextAsync(prsFile, prsFileContent, TestContext.Current.CancellationToken); @@ -534,17 +459,15 @@ public async Task CreateChangelog_WithMixedPrsFromFileAndCommaSeparated_Processe var allPrs = new List(); // Add comma-separated PRs - var commaSeparatedPrs = - "https://github.com/elastic/elasticsearch/pull/1111".Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var commaSeparatedPrs = "https://github.com/elastic/elasticsearch/pull/1111".Split( + ',', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries + ); allPrs.AddRange(commaSeparatedPrs); // Add PRs from file var prsFromFile = await FileSystem.File.ReadAllLinesAsync(prsFile, TestContext.Current.CancellationToken); - allPrs.AddRange( - prsFromFile - .Where(line => !string.IsNullOrWhiteSpace(line)) - .Select(line => line.Trim()) - ); + allPrs.AddRange(prsFromFile.Where(line => !string.IsNullOrWhiteSpace(line)).Select(line => line.Trim())); // language=yaml var configContent = @@ -566,6 +489,7 @@ public async Task CreateChangelog_WithMixedPrsFromFileAndCommaSeparated_Processe var input = new CreateChangelogArguments { Prs = allPrs.ToArray(), // Mixed PRs from comma-separated and file + Products = [new ProductArgument { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], Config = configPath, Output = CreateOutputDirectory() @@ -605,18 +529,9 @@ public async Task CreateChangelog_WithBareNumberPrAndOwnerRepo_WritesFullUrlInto // docs-builder changelog add --concise --use-pr-number --owner elastic --repo cloud --prs 155500 // The previous bare-number-in YAML was unparseable by the scrubber Lambda (which has no per-entry // repo context), so the writer must normalize bare numbers to full URLs when owner+repo are known. - var prInfo = new GitHubPrInfo - { - Title = "Fix upload failures for extensions with >5GiB", - Labels = ["type:bug"] - }; + var prInfo = new GitHubPrInfo { Title = "Fix upload failures for extensions with >5GiB", Labels = ["type:bug"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - "155500", - "elastic", - "cloud", - A._)) - .Returns(prInfo); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync("155500", "elastic", "cloud", A._)).Returns(prInfo); // language=yaml var configContent = diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/ReleaseNoteExtractionTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/ReleaseNoteExtractionTests.cs index 900013ac71..96de57aef4 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/ReleaseNoteExtractionTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/ReleaseNoteExtractionTests.cs @@ -24,12 +24,15 @@ public async Task CreateChangelog_WithExtractReleaseNotes_ShortReleaseNote_UsesP Labels = ["type:feature"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - "https://github.com/elastic/elasticsearch/pull/12345", - null, - null, - A._)) - .Returns(prInfo); + A.CallTo( + () => + MockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._ + ) + ).Returns(prInfo); // language=yaml var configContent = @@ -88,12 +91,15 @@ public async Task CreateChangelog_WithExtractReleaseNotes_LongReleaseNote_UsesAs Labels = ["type:feature"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - "https://github.com/elastic/elasticsearch/pull/12345", - null, - null, - A._)) - .Returns(prInfo); + A.CallTo( + () => + MockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._ + ) + ).Returns(prInfo); // language=yaml var configContent = @@ -144,7 +150,8 @@ public async Task CreateChangelog_WithExtractReleaseNotes_MultiLineReleaseNote_U { // Arrange // The regex stops at double newline, so we need a release note that spans multiple lines without double newline - var multiLineReleaseNote = "Adds support for new aggregation types\nThis includes date histogram and range aggregations\nwith improved performance"; + var multiLineReleaseNote = + "Adds support for new aggregation types\nThis includes date histogram and range aggregations\nwith improved performance"; var prInfo = new GitHubPrInfo { Title = "Implement new aggregation API", @@ -152,12 +159,15 @@ public async Task CreateChangelog_WithExtractReleaseNotes_MultiLineReleaseNote_U Labels = ["type:feature"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - "https://github.com/elastic/elasticsearch/pull/12345", - null, - null, - A._)) - .Returns(prInfo); + A.CallTo( + () => + MockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._ + ) + ).Returns(prInfo); // language=yaml var configContent = @@ -216,12 +226,15 @@ public async Task CreateChangelog_WithExtractReleaseNotes_NoReleaseNote_UsesPrTi Labels = ["type:feature"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - "https://github.com/elastic/elasticsearch/pull/12345", - null, - null, - A._)) - .Returns(prInfo); + A.CallTo( + () => + MockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._ + ) + ).Returns(prInfo); // language=yaml var configContent = @@ -284,12 +297,15 @@ public async Task CreateChangelog_WithExtractReleaseNotes_ExplicitTitle_TakesPre Labels = ["type:feature"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - "https://github.com/elastic/elasticsearch/pull/12345", - null, - null, - A._)) - .Returns(prInfo); + A.CallTo( + () => + MockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._ + ) + ).Returns(prInfo); // language=yaml var configContent = @@ -349,12 +365,15 @@ public async Task CreateChangelog_WithExtractReleaseNotes_ExplicitDescription_Ta Labels = ["type:feature"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - "https://github.com/elastic/elasticsearch/pull/12345", - null, - null, - A._)) - .Returns(prInfo); + A.CallTo( + () => + MockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._ + ) + ).Returns(prInfo); // language=yaml var configContent = @@ -414,12 +433,15 @@ public async Task CreateChangelog_WhenExtractNotSpecifiedByCli_UsesConfigExtract Labels = ["type:feature"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - "https://github.com/elastic/elasticsearch/pull/12345", - null, - null, - A._)) - .Returns(prInfo); + A.CallTo( + () => + MockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._ + ) + ).Returns(prInfo); // language=yaml var configContent = @@ -446,7 +468,8 @@ public async Task CreateChangelog_WhenExtractNotSpecifiedByCli_UsesConfigExtract Products = [new ProductArgument { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], Config = configPath, Output = CreateOutputDirectory(), - ExtractReleaseNotes = null // CLI did not specify; config default applies + ExtractReleaseNotes = + null // CLI did not specify; config default applies }; // Act @@ -476,12 +499,15 @@ public async Task CreateChangelog_InCI_ExtractionEnabledByConfig_PreservesCIDesc Labels = ["type:feature"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - "https://github.com/elastic/elasticsearch/pull/12345", - null, - null, - A._)) - .Returns(prInfo); + A.CallTo( + () => + MockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._ + ) + ).Returns(prInfo); // language=yaml var configContent = @@ -544,12 +570,15 @@ public async Task CreateChangelog_InCI_ExtractionDisabledByCli_ClearsCIDescripti Labels = ["type:feature"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - "https://github.com/elastic/elasticsearch/pull/12345", - null, - null, - A._)) - .Returns(prInfo); + A.CallTo( + () => + MockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._ + ) + ).Returns(prInfo); // language=yaml var configContent = @@ -619,18 +648,14 @@ public async Task CreateChangelog_InCI_MultiplePrs_ExtractionDisabled_ClearsCIDe Labels = ["type:bug-fix"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - "https://github.com/elastic/elasticsearch/pull/100", - null, - null, - A._)) - .Returns(prInfo1); - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - "https://github.com/elastic/elasticsearch/pull/200", - null, - null, - A._)) - .Returns(prInfo2); + A.CallTo( + () => + MockGitHubService.FetchPrInfoAsync("https://github.com/elastic/elasticsearch/pull/100", null, null, A._) + ).Returns(prInfo1); + A.CallTo( + () => + MockGitHubService.FetchPrInfoAsync("https://github.com/elastic/elasticsearch/pull/200", null, null, A._) + ).Returns(prInfo2); // language=yaml var configContent = @@ -664,11 +689,7 @@ public async Task CreateChangelog_InCI_MultiplePrs_ExtractionDisabled_ClearsCIDe var output = CreateOutputDirectory(); var input = new CreateChangelogArguments { - Prs = - [ - "https://github.com/elastic/elasticsearch/pull/100", - "https://github.com/elastic/elasticsearch/pull/200" - ], + Prs = ["https://github.com/elastic/elasticsearch/pull/100", "https://github.com/elastic/elasticsearch/pull/200"], Products = [new ProductArgument { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], Config = configPath, Output = output, @@ -703,12 +724,15 @@ public async Task CreateChangelog_InCI_ExtractionDisabledByConfig_ClearsCIDescri Labels = ["type:feature"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - "https://github.com/elastic/elasticsearch/pull/12345", - null, - null, - A._)) - .Returns(prInfo); + A.CallTo( + () => + MockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._ + ) + ).Returns(prInfo); // language=yaml var configContent = @@ -774,12 +798,15 @@ public async Task CreateChangelog_InCI_CliEnablesExtraction_OverridesConfigFalse Labels = ["type:feature"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - "https://github.com/elastic/elasticsearch/pull/12345", - null, - null, - A._)) - .Returns(prInfo); + A.CallTo( + () => + MockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._ + ) + ).Returns(prInfo); // language=yaml var configContent = @@ -842,12 +869,15 @@ public async Task CreateChangelog_InCI_ExtractionDisabled_ExplicitCliDescription Labels = ["type:feature"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - "https://github.com/elastic/elasticsearch/pull/12345", - null, - null, - A._)) - .Returns(prInfo); + A.CallTo( + () => + MockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._ + ) + ).Returns(prInfo); // language=yaml var configContent = diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/ReleaseVersionTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/ReleaseVersionTests.cs index 6d144f0bf1..2ae9c2054e 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/ReleaseVersionTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/ReleaseVersionTests.cs @@ -23,8 +23,7 @@ public class ReleaseVersionTests(ITestOutputHelper output) : ChangelogTestBase(o private GitHubReleaseChangelogService CreateService() => new(LoggerFactory, ConfigurationContext, FileSystem, _mockReleaseService, _mockPrService); - private string CreateOutputDirectory() => - FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + private string CreateOutputDirectory() => FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); // ----------------------------------------------------------------------- // Validation: no PR refs in release notes @@ -34,13 +33,14 @@ private string CreateOutputDirectory() => public async Task ReleaseVersion_WithNoMatchingPrs_EmitsWarningAndSucceeds() { // Arrange - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._)) - .Returns(new GitHubReleaseInfo - { - TagName = "v9.2.0", - Name = "9.2.0", - Body = "No pull request references in these release notes." - }); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._) + ).Returns(new GitHubReleaseInfo + { + TagName = "v9.2.0", + Name = "9.2.0", + Body = "No pull request references in these release notes." + }); var service = CreateService(); var input = new CreateChangelogsFromReleaseArguments @@ -56,8 +56,9 @@ public async Task ReleaseVersion_WithNoMatchingPrs_EmitsWarningAndSucceeds() // Assert result.Should().BeTrue(); - Collector.Diagnostics.Should().Contain(d => - d.Message.Contains("No PR references found") && d.Severity == Documentation.Diagnostics.Severity.Warning); + Collector.Diagnostics + .Should() + .Contain(d => d.Message.Contains("No PR references found") && d.Severity == Documentation.Diagnostics.Severity.Warning); } // ----------------------------------------------------------------------- @@ -79,11 +80,15 @@ public async Task ReleaseVersion_WithValidRelease_CreatesChangelogFiles_AndNoBun **Full Changelog**: https://github.com/elastic/elasticsearch/compare/v9.1.0...v9.2.0 """; - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._)) - .Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._) + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); - A.CallTo(() => _mockPrService.FetchPrInfoAsync(A._, A._, A._, A._)) - .Returns(new GitHubPrInfo { Title = "PR title", Labels = [] }); + A.CallTo(() => _mockPrService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns(new GitHubPrInfo + { + Title = "PR title", + Labels = [] + }); var outputDir = CreateOutputDirectory(); FileSystem.Directory.CreateDirectory(outputDir); @@ -129,11 +134,15 @@ public async Task GhRelease_WithValidRelease_CreatesBundleFile() **Full Changelog**: https://github.com/elastic/elasticsearch/compare/v9.1.0...v9.2.0 """; - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._)) - .Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._) + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); - A.CallTo(() => _mockPrService.FetchPrInfoAsync(A._, A._, A._, A._)) - .Returns(new GitHubPrInfo { Title = "Add aggregation API", Labels = [] }); + A.CallTo(() => _mockPrService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns(new GitHubPrInfo + { + Title = "Add aggregation API", + Labels = [] + }); var outputDir = CreateOutputDirectory(); FileSystem.Directory.CreateDirectory(outputDir); @@ -168,13 +177,9 @@ public async Task GhRelease_WithValidRelease_CreatesBundleFile() public async Task ReleaseVersion_Latest_CallsFetchWithLatestTag() { // Arrange - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "latest", A._)) - .Returns(new GitHubReleaseInfo - { - TagName = "v9.2.0", - Name = "9.2.0", - Body = "No PR references." - }); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "latest", A._) + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = "No PR references." }); var service = CreateService(); var input = new CreateChangelogsFromReleaseArguments @@ -189,8 +194,9 @@ public async Task ReleaseVersion_Latest_CallsFetchWithLatestTag() _ = await service.CreateChangelogsFromRelease(Collector, input, TestContext.Current.CancellationToken); // Assert - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "latest", A._)) - .MustHaveHappenedOnceExactly(); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "latest", A._) + ).MustHaveHappenedOnceExactly(); } // ----------------------------------------------------------------------- @@ -201,8 +207,9 @@ public async Task ReleaseVersion_Latest_CallsFetchWithLatestTag() public async Task ReleaseVersion_FetchFailure_ReturnsError() { // Arrange - A.CallTo(() => _mockReleaseService.FetchReleaseAsync(A._, A._, A._, A._)) - .Returns((GitHubReleaseInfo?)null); + A.CallTo(() => _mockReleaseService.FetchReleaseAsync(A._, A._, A._, A._)).Returns( + (GitHubReleaseInfo?)null + ); var service = CreateService(); var input = new CreateChangelogsFromReleaseArguments @@ -258,16 +265,15 @@ public async Task ReleaseVersion_OutputNull_ServiceUsesChangelogsDefault() { // Arrange – simulates 'changelog add --release-version' with no --output and no bundle.directory in config. // The command passes Output = null to the service; the service must default to "./changelogs". - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._)) - .Returns(new GitHubReleaseInfo - { - TagName = "v9.2.0", - Name = "9.2.0", - Body = "* Fix something by @contributor in #12345" - }); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._) + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = "* Fix something by @contributor in #12345" }); - A.CallTo(() => _mockPrService.FetchPrInfoAsync(A._, A._, A._, A._)) - .Returns(new GitHubPrInfo { Title = "Fix something", Labels = [] }); + A.CallTo(() => _mockPrService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns(new GitHubPrInfo + { + Title = "Fix something", + Labels = [] + }); var workDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(workDir); @@ -281,7 +287,8 @@ public async Task ReleaseVersion_OutputNull_ServiceUsesChangelogsDefault() { Repository = "elastic/elasticsearch", Version = "v9.2.0", - Output = null, // no --output CLI and no bundle.directory in config + Output = null, // no --output CLI and no bundle.directory in config + CreateBundle = false }; diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/TitleProcessingTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/TitleProcessingTests.cs index aa4c36077a..5fa777a4b8 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/TitleProcessingTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/TitleProcessingTests.cs @@ -15,18 +15,17 @@ public class TitleProcessingTests(ITestOutputHelper output) : CreateChangelogTes public async Task CreateChangelog_WithStripTitlePrefix_RemovesSquareBracketsAndColon() { // Arrange - var prInfo = new GitHubPrInfo - { - Title = "[ES|QL]: Update Vector Similarity To Support BFLOAT16", - Labels = ["type:feature"] - }; - - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - "https://github.com/elastic/elasticsearch/pull/12345", - null, - null, - A._)) - .Returns(prInfo); + var prInfo = new GitHubPrInfo { Title = "[ES|QL]: Update Vector Similarity To Support BFLOAT16", Labels = ["type:feature"] }; + + A.CallTo( + () => + MockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._ + ) + ).Returns(prInfo); // language=yaml var configContent = @@ -77,18 +76,17 @@ public async Task CreateChangelog_WithStripTitlePrefix_RemovesSquareBracketsAndC public async Task CreateChangelog_WithStripTitlePrefix_RemovesSquareBracketsWithoutColon() { // Arrange - var prInfo = new GitHubPrInfo - { - Title = "[Security] Improve authentication handling", - Labels = ["type:feature"] - }; - - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - "https://github.com/elastic/elasticsearch/pull/12345", - null, - null, - A._)) - .Returns(prInfo); + var prInfo = new GitHubPrInfo { Title = "[Security] Improve authentication handling", Labels = ["type:feature"] }; + + A.CallTo( + () => + MockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._ + ) + ).Returns(prInfo); // language=yaml var configContent = @@ -138,18 +136,17 @@ public async Task CreateChangelog_WithStripTitlePrefix_RemovesSquareBracketsWith public async Task CreateChangelog_WithStripTitlePrefix_RemovesMultipleSquareBracketPrefixes() { // Arrange - var prInfo = new GitHubPrInfo - { - Title = "[Discover][ESQL] Fix filtering by multiline string fields", - Labels = ["type:bug-fix"] - }; - - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - "https://github.com/elastic/elasticsearch/pull/12345", - null, - null, - A._)) - .Returns(prInfo); + var prInfo = new GitHubPrInfo { Title = "[Discover][ESQL] Fix filtering by multiline string fields", Labels = ["type:bug-fix"] }; + + A.CallTo( + () => + MockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/elasticsearch/pull/12345", + null, + null, + A._ + ) + ).Returns(prInfo); // language=yaml var configContent = @@ -199,18 +196,11 @@ public async Task CreateChangelog_WithStripTitlePrefix_RemovesMultipleSquareBrac [Fact] public async Task CreateChangelog_WithStripTitlePrefix_StripsKibanaStyleTeamHyphenSeparator() { - var prInfo = new GitHubPrInfo - { - Title = "[Cases] - Enable cases numerical id service", - Labels = ["type:feature"] - }; + var prInfo = new GitHubPrInfo { Title = "[Cases] - Enable cases numerical id service", Labels = ["type:feature"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - "https://github.com/elastic/kibana/pull/238555", - null, - null, - A._)) - .Returns(prInfo); + A.CallTo( + () => MockGitHubService.FetchPrInfoAsync("https://github.com/elastic/kibana/pull/238555", null, null, A._) + ).Returns(prInfo); var configContent = """ @@ -257,18 +247,9 @@ public async Task CreateChangelog_WithStripTitlePrefix_StripsKibanaStyleTeamHyph public async Task CreateChangelog_WithExplicitTitle_OverridesPrTitle() { // Arrange - var prInfo = new GitHubPrInfo - { - Title = "PR Title from GitHub", - Labels = [] - }; + var prInfo = new GitHubPrInfo { Title = "PR Title from GitHub", Labels = [] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A._, - A._, - A._, - A._)) - .Returns(prInfo); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns(prInfo); var service = CreateService(); @@ -315,11 +296,7 @@ public async Task CreateChangelog_WithIssues_CreatesValidYaml() Title = "Fix multiple issues", Type = "bug-fix", Products = [new ProductArgument { Product = "elasticsearch", Target = "9.2.0" }], - Issues = - [ - "https://github.com/elastic/elasticsearch/issues/123", - "https://github.com/elastic/elasticsearch/issues/456" - ], + Issues = ["https://github.com/elastic/elasticsearch/issues/123", "https://github.com/elastic/elasticsearch/issues/456"], Output = CreateOutputDirectory() }; diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/ValidationTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/ValidationTests.cs index f454788cb8..65489d3cf5 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/ValidationTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/ValidationTests.cs @@ -15,18 +15,9 @@ public class ValidationTests(ITestOutputHelper output) : CreateChangelogTestBase public async Task CreateChangelog_WithPrOptionButNoLabelMapping_ReturnsError() { // Arrange - var prInfo = new GitHubPrInfo - { - Title = "Some PR", - Labels = ["some-label"] - }; + var prInfo = new GitHubPrInfo { Title = "Some PR", Labels = ["some-label"] }; - A.CallTo(() => MockGitHubService.FetchPrInfoAsync( - A._, - A._, - A._, - A._)) - .Returns(prInfo); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync(A._, A._, A._, A._)).Returns(prInfo); // Config without pivot.types mapping // language=yaml @@ -150,8 +141,9 @@ public async Task CreateChangelog_WithInvalidProductInAddBlockers_ReturnsError() // Assert result.Should().BeFalse(); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Message.Contains("invalid-product") && d.Message.Contains("not in available products")); + Collector.Diagnostics + .Should() + .Contain(d => d.Message.Contains("invalid-product") && d.Message.Contains("not in available products")); } [Fact] diff --git a/tests/Elastic.Changelog.Tests/Changelogs/DiagnosticsCollectorDisposeTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/DiagnosticsCollectorDisposeTests.cs index 17c16085cf..8e1331b3a1 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/DiagnosticsCollectorDisposeTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/DiagnosticsCollectorDisposeTests.cs @@ -35,8 +35,11 @@ public async Task DisposeAsync_WithoutStartAsyncAfterEmit_DoesNotHang() var collector = new DiagnosticsCollector([output]); collector.EmitWarning("file.yaml", "test warning that nobody is reading"); - await ShouldComplete(collector.DisposeAsync().AsTask(), TimeSpan.FromSeconds(5), - "DisposeAsync must not deadlock when StartAsync was never called"); + await ShouldComplete( + collector.DisposeAsync().AsTask(), + TimeSpan.FromSeconds(5), + "DisposeAsync must not deadlock when StartAsync was never called" + ); collector.Warnings.Should().Be(1, "severity counters update regardless of reader state"); collector.IsStarted.Should().BeFalse(); @@ -51,8 +54,11 @@ public async Task StopAsync_WithoutStartAsyncAfterEmit_DoesNotHang() var collector = new DiagnosticsCollector([output]); collector.EmitError("file.yaml", "test error that nobody is reading"); - await ShouldComplete(collector.StopAsync(CancellationToken.None), TimeSpan.FromSeconds(5), - "StopAsync must not deadlock when StartAsync was never called"); + await ShouldComplete( + collector.StopAsync(CancellationToken.None), + TimeSpan.FromSeconds(5), + "StopAsync must not deadlock when StartAsync was never called" + ); collector.Errors.Should().Be(1); collector.IsStarted.Should().BeFalse(); @@ -64,8 +70,11 @@ public async Task DisposeAsync_WithoutStartAsyncAndNoEmissions_DoesNotHang() { var collector = new DiagnosticsCollector([]); - await ShouldComplete(collector.DisposeAsync().AsTask(), TimeSpan.FromSeconds(5), - "Instantiate-and-dispose with no emissions must be a no-op"); + await ShouldComplete( + collector.DisposeAsync().AsTask(), + TimeSpan.FromSeconds(5), + "Instantiate-and-dispose with no emissions must be a no-op" + ); collector.IsStarted.Should().BeFalse(); collector.Warnings.Should().Be(0); @@ -98,8 +107,11 @@ public async Task WaitForDrain_AfterStartAsync_WaitsForReaderEvenIfNotStartedYet iface.EmitWarning(string.Empty, "should be drained"); // WaitForDrain must not throw even though IsStarted may still be false here. - await ShouldComplete(iface.WaitForDrain(), TimeSpan.FromSeconds(5), - "WaitForDrain must not throw when StartAsync was called but reader hasn't started yet"); + await ShouldComplete( + iface.WaitForDrain(), + TimeSpan.FromSeconds(5), + "WaitForDrain must not throw when StartAsync was called but reader hasn't started yet" + ); collector.Warnings.Should().Be(1); output.Items.Should().HaveCount(1, "the item must be drained once the reader starts"); @@ -128,7 +140,9 @@ public async Task WaitForDrain_ReaderNeverStarts_TimesOutInVirtualTime() drain.IsCompleted.Should().BeTrue("the 2s virtual deadline must trip long before 50s of virtual time"); Func act = () => drain; - _ = (await act.Should().ThrowAsync()) - .WithMessage("*timed out waiting for the background reader to start*"); + _ = + (await act.Should().ThrowAsync()).WithMessage( + "*timed out waiting for the background reader to start*" + ); } } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/GitHubCommitRangeServiceTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/GitHubCommitRangeServiceTests.cs index 07e8b55ca5..a06b3896e8 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/GitHubCommitRangeServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/GitHubCommitRangeServiceTests.cs @@ -22,13 +22,7 @@ public class GitHubCommitRangeServiceTests(ITestOutputHelper output) : Changelog private const string Owner = "elastic"; private const string Repo = "widget"; - private static readonly CommitRangeArguments Args = new() - { - Owner = Owner, - Repo = Repo, - StartRef = "startsha", - EndRef = "endsha" - }; + private static readonly CommitRangeArguments Args = new() { Owner = Owner, Repo = Repo, StartRef = "startsha", EndRef = "endsha" }; private static string Sha(int i) => i.ToString(CultureInfo.InvariantCulture).PadLeft(40, '0'); @@ -53,7 +47,11 @@ private static string GraphQlJson(IReadOnlyList<(string Sha, string[] PrNodes)> { if (i > 0) _ = sb.Append(','); - _ = sb.Append(CultureInfo.InvariantCulture, $$""" "c{{i}}": { "oid": "{{commits[i].Sha}}", "associatedPullRequests": { "nodes": [{{string.Join(",", commits[i].PrNodes)}}] } }"""); + _ = + sb.Append( + CultureInfo.InvariantCulture, + $$""" "c{{i}}": { "oid": "{{commits[i].Sha}}", "associatedPullRequests": { "nodes": [{{string.Join(",", commits[i].PrNodes)}}] } }""" + ); } return $$"""{ "data": { "repository": {{{sb}} } } }"""; @@ -62,7 +60,10 @@ private static string GraphQlJson(IReadOnlyList<(string Sha, string[] PrNodes)> private GitHubCommitRangeService Service(StubHandler handler) => new(new TestLoggerFactory(Output), new GitHubApiTransport(handler, "test-token")); - private static StubHandler Handler(Func compareResponder, Func graphQlResponder) => + private static StubHandler Handler( + Func compareResponder, + Func graphQlResponder + ) => new(req => { var path = req.RequestUri!.AbsolutePath; @@ -83,7 +84,8 @@ public async Task ResolvePullRequests_SquashCommits_ResolvesOnePrPerCommitInRang var (sha1, sha2) = (Sha(1), Sha(2)); var handler = Handler( _ => CompareJson(2, [sha1, sha2]), - _ => GraphQlJson([(sha1, [PrNode(11, mergeCommitSha: sha1)]), (sha2, [PrNode(12, mergeCommitSha: sha2)])])); + _ => GraphQlJson([(sha1, [PrNode(11, mergeCommitSha: sha1)]), (sha2, [PrNode(12, mergeCommitSha: sha2)])]) + ); var result = await Service(handler).ResolvePullRequestsAsync(Collector, Args, TestContext.Current.CancellationToken); @@ -104,11 +106,13 @@ public async Task ResolvePullRequests_MergeCommitPr_DeduplicatesAcrossCommits() var (sha1, sha2, mergeSha) = (Sha(1), Sha(2), Sha(3)); var handler = Handler( _ => CompareJson(3, [sha1, sha2, mergeSha]), - _ => GraphQlJson([ - (sha1, [PrNode(20, mergeCommitSha: mergeSha)]), - (sha2, [PrNode(20, mergeCommitSha: mergeSha)]), - (mergeSha, [PrNode(20, mergeCommitSha: mergeSha)]) - ])); + _ => + GraphQlJson([ + (sha1, [PrNode(20, mergeCommitSha: mergeSha)]), + (sha2, [PrNode(20, mergeCommitSha: mergeSha)]), + (mergeSha, [PrNode(20, mergeCommitSha: mergeSha)]) + ]) + ); var result = await Service(handler).ResolvePullRequestsAsync(Collector, Args, TestContext.Current.CancellationToken); @@ -126,11 +130,13 @@ public async Task ResolvePullRequests_CommitWithoutMergedPr_IsReportedNotDropped var (sha1, sha2, sha3) = (Sha(1), Sha(2), Sha(3)); var handler = Handler( _ => CompareJson(3, [sha1, sha2, sha3]), - _ => GraphQlJson([ - (sha1, []), - (sha2, [PrNode(30, merged: false)]), - (sha3, [PrNode(31, mergeCommitSha: sha3, repoFullName: "someone/fork")]) - ])); + _ => + GraphQlJson([ + (sha1, []), + (sha2, [PrNode(30, merged: false)]), + (sha3, [PrNode(31, mergeCommitSha: sha3, repoFullName: "someone/fork")]) + ]) + ); var result = await Service(handler).ResolvePullRequestsAsync(Collector, Args, TestContext.Current.CancellationToken); @@ -143,17 +149,14 @@ public async Task ResolvePullRequests_CommitWithoutMergedPr_IsReportedNotDropped public async Task ResolvePullRequests_MultipleMergedPrs_WarnsAndPicksDeterministically() { var sha1 = Sha(1); - var handler = Handler( - _ => CompareJson(1, [sha1]), - _ => GraphQlJson([(sha1, [PrNode(42), PrNode(7)])])); + var handler = Handler(_ => CompareJson(1, [sha1]), _ => GraphQlJson([(sha1, [PrNode(42), PrNode(7)])])); var result = await Service(handler).ResolvePullRequestsAsync(Collector, Args, TestContext.Current.CancellationToken); result.Should().NotBeNull(); result.PullRequests.Should().ContainSingle(); result.PullRequests[0].Number.Should().Be(7, "ambiguity resolves deterministically to the lowest PR number"); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Warning && d.Message.Contains("multiple merged pull requests")); + Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Warning && d.Message.Contains("multiple merged pull requests")); } [Fact] @@ -162,9 +165,7 @@ public async Task ResolvePullRequests_MergeCommitMatchWins_NoAmbiguityWarning() // A commit associated with two merged PRs, but exactly one of them has this commit as its // merge commit — that one wins without a warning. var sha1 = Sha(1); - var handler = Handler( - _ => CompareJson(1, [sha1]), - _ => GraphQlJson([(sha1, [PrNode(50), PrNode(60, mergeCommitSha: sha1)])])); + var handler = Handler(_ => CompareJson(1, [sha1]), _ => GraphQlJson([(sha1, [PrNode(50), PrNode(60, mergeCommitSha: sha1)])])); var result = await Service(handler).ResolvePullRequestsAsync(Collector, Args, TestContext.Current.CancellationToken); @@ -197,10 +198,9 @@ public async Task ResolvePullRequests_Pagination_FollowsAllComparePages() // resolves to its own squashed PR (number = index + 1). var batchIndex = graphQlBatches++; var batch = shas.Skip(batchIndex * 50).Take(50).ToList(); - return GraphQlJson(batch - .Select(sha => (sha, new[] { PrNode(shas.IndexOf(sha) + 1, mergeCommitSha: sha) })) - .ToList()); - }); + return GraphQlJson(batch.Select(sha => (sha, new[] { PrNode(shas.IndexOf(sha) + 1, mergeCommitSha: sha) })).ToList()); + } + ); var result = await Service(handler).ResolvePullRequestsAsync(Collector, Args, TestContext.Current.CancellationToken); @@ -219,7 +219,8 @@ public async Task ResolvePullRequests_EmptyRange_WarnsAndReturnsEmptyResolution( { var handler = Handler( _ => CompareJson(0, [], status: "identical"), - _ => throw new InvalidOperationException("GraphQL must not be called for an empty range")); + _ => throw new InvalidOperationException("GraphQL must not be called for an empty range") + ); var result = await Service(handler).ResolvePullRequestsAsync(Collector, Args, TestContext.Current.CancellationToken); @@ -243,7 +244,10 @@ public async Task ResolvePullRequests_UnknownRefs_EmitsErrorAndReturnsNull() [Fact] public async Task ResolvePullRequests_MissingToken_EmitsErrorWithoutAnyRequest() { - var handler = Handler(_ => throw new InvalidOperationException("no request expected"), _ => throw new InvalidOperationException("no request expected")); + var handler = Handler( + _ => throw new InvalidOperationException("no request expected"), + _ => throw new InvalidOperationException("no request expected") + ); var service = new GitHubCommitRangeService(new TestLoggerFactory(Output), new GitHubApiTransport(handler, "")); var result = await service.ResolvePullRequestsAsync(Collector, Args, TestContext.Current.CancellationToken); @@ -259,7 +263,8 @@ public async Task ResolvePullRequests_GraphQlErrors_EmitError() var sha1 = Sha(1); var handler = Handler( _ => CompareJson(1, [sha1]), - _ => /*lang=json,strict*/ """{ "data": null, "errors": [ { "message": "boom" } ] }"""); + _ => /*lang=json,strict*/ """{ "data": null, "errors": [ { "message": "boom" } ] }""" + ); var result = await Service(handler).ResolvePullRequestsAsync(Collector, Args, TestContext.Current.CancellationToken); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/LinkAllowlistSanitizerTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/LinkAllowlistSanitizerTests.cs index 3184ef4b9c..44d41a93db 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/LinkAllowlistSanitizerTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/LinkAllowlistSanitizerTests.cs @@ -22,7 +22,8 @@ public void TryGetGitHubRepo_FullUrl_ParsesOwnerRepo() "elastic", "elasticsearch", out var owner, - out var repo); + out var repo + ); ok.Should().BeTrue(); owner.Should().Be("elastic"); @@ -37,7 +38,8 @@ public void TryGetGitHubRepo_ShortForm_ParsesOwnerRepo() "elastic", "elasticsearch", out var owner, - out var repo); + out var repo + ); ok.Should().BeTrue(); owner.Should().Be("elastic"); @@ -52,7 +54,8 @@ public void TryGetGitHubRepo_PullUrl_InvalidNumber_ReturnsFalse() "elastic", "elasticsearch", out _, - out _); + out _ + ); ok.Should().BeFalse(); } @@ -60,12 +63,7 @@ public void TryGetGitHubRepo_PullUrl_InvalidNumber_ReturnsFalse() [Fact] public void TryGetGitHubRepo_ShortForm_NonNumericFragment_ReturnsFalse() { - var ok = ChangelogTextUtilities.TryGetGitHubRepo( - "elastic/kibana-team#abc", - "elastic", - "elasticsearch", - out _, - out _); + var ok = ChangelogTextUtilities.TryGetGitHubRepo("elastic/kibana-team#abc", "elastic", "elasticsearch", out _, out _); ok.Should().BeFalse(); } @@ -73,12 +71,7 @@ public void TryGetGitHubRepo_ShortForm_NonNumericFragment_ReturnsFalse() [Fact] public void TryGetGitHubRepo_ShortForm_TooManySlashes_ReturnsFalse() { - var ok = ChangelogTextUtilities.TryGetGitHubRepo( - "a/b/c#123", - "elastic", - "elasticsearch", - out _, - out _); + var ok = ChangelogTextUtilities.TryGetGitHubRepo("a/b/c#123", "elastic", "elasticsearch", out _, out _); ok.Should().BeFalse(); } @@ -86,12 +79,7 @@ public void TryGetGitHubRepo_ShortForm_TooManySlashes_ReturnsFalse() [Fact] public void TryGetGitHubRepo_BareNumber_UsesDefaults() { - var ok = ChangelogTextUtilities.TryGetGitHubRepo( - "123", - "elastic", - "elasticsearch+kibana", - out var owner, - out var repo); + var ok = ChangelogTextUtilities.TryGetGitHubRepo("123", "elastic", "elasticsearch+kibana", out var owner, out var repo); ok.Should().BeTrue(); owner.Should().Be("elastic"); @@ -129,7 +117,8 @@ public void TryApplyBundle_AllowedRepo_KeepsUrl() "elastic", "elasticsearch", out var sanitized, - out var changed); + out var changed + ); ok.Should().BeTrue(); changed.Should().BeFalse(); @@ -141,18 +130,7 @@ public void TryApplyBundle_AllowedRepo_KeepsUrl() [Fact] public void TryApplyBundle_NullPrsAndIssues_PreservesNull_WhenUnchanged() { - var bundle = new Bundle - { - Entries = - [ - new() - { - Title = "t", - Prs = null, - Issues = null - } - ] - }; + var bundle = new Bundle { Entries = [new() { Title = "t", Prs = null, Issues = null }] }; var allow = new[] { "elastic/elasticsearch" }; var ok = LinkAllowlistSanitizer.TryApplyBundle( @@ -162,7 +140,8 @@ public void TryApplyBundle_NullPrsAndIssues_PreservesNull_WhenUnchanged() "elastic", "elasticsearch", out var sanitized, - out var changed); + out var changed + ); ok.Should().BeTrue(); changed.Should().BeFalse(); @@ -173,17 +152,7 @@ public void TryApplyBundle_NullPrsAndIssues_PreservesNull_WhenUnchanged() [Fact] public void TryApplyBundle_NotAllowed_ReplacesWithSentinel() { - var bundle = new Bundle - { - Entries = - [ - new() - { - Title = "t", - Prs = ["https://github.com/elastic/secret-repo/pull/1"] - } - ] - }; + var bundle = new Bundle { Entries = [new() { Title = "t", Prs = ["https://github.com/elastic/secret-repo/pull/1"] }] }; var allow = new[] { "elastic/elasticsearch" }; var ok = LinkAllowlistSanitizer.TryApplyBundle( @@ -193,7 +162,8 @@ public void TryApplyBundle_NotAllowed_ReplacesWithSentinel() "elastic", "elasticsearch", out var sanitized, - out var changed); + out var changed + ); ok.Should().BeTrue(); changed.Should().BeTrue(); @@ -204,10 +174,7 @@ public void TryApplyBundle_NotAllowed_ReplacesWithSentinel() [Fact] public void TryApplyBundle_EmptyAllowlist_StripsAll() { - var bundle = new Bundle - { - Entries = [new() { Title = "t", Prs = ["https://github.com/elastic/elasticsearch/pull/1"] }] - }; + var bundle = new Bundle { Entries = [new() { Title = "t", Prs = ["https://github.com/elastic/elasticsearch/pull/1"] }] }; var ok = LinkAllowlistSanitizer.TryApplyBundle( Collector, @@ -216,7 +183,8 @@ public void TryApplyBundle_EmptyAllowlist_StripsAll() "elastic", "elasticsearch", out var sanitized, - out var changed); + out var changed + ); ok.Should().BeTrue(); changed.Should().BeTrue(); @@ -226,10 +194,7 @@ public void TryApplyBundle_EmptyAllowlist_StripsAll() [Fact] public void TryApplyBundle_UnparseableRef_EmitsError() { - var bundle = new Bundle - { - Entries = [new() { Title = "t", Prs = ["not-a-valid-ref"] }] - }; + var bundle = new Bundle { Entries = [new() { Title = "t", Prs = ["not-a-valid-ref"] }] }; var ok = LinkAllowlistSanitizer.TryApplyBundle( Collector, @@ -238,7 +203,8 @@ public void TryApplyBundle_UnparseableRef_EmitsError() "elastic", "elasticsearch", out _, - out _); + out _ + ); ok.Should().BeFalse(); Collector.Errors.Should().BeGreaterThan(0); @@ -247,17 +213,7 @@ public void TryApplyBundle_UnparseableRef_EmitsError() [Fact] public void TryApplyBundle_SentinelAllowed_RestoresPlainRef() { - var bundle = new Bundle - { - Entries = - [ - new() - { - Title = "t", - Prs = ["# PRIVATE: https://github.com/elastic/elasticsearch/pull/1"] - } - ] - }; + var bundle = new Bundle { Entries = [new() { Title = "t", Prs = ["# PRIVATE: https://github.com/elastic/elasticsearch/pull/1"] }] }; var ok = LinkAllowlistSanitizer.TryApplyBundle( Collector, @@ -266,7 +222,8 @@ public void TryApplyBundle_SentinelAllowed_RestoresPlainRef() "elastic", "elasticsearch", out var sanitized, - out var changed); + out var changed + ); ok.Should().BeTrue(); changed.Should().BeTrue(); @@ -276,17 +233,7 @@ public void TryApplyBundle_SentinelAllowed_RestoresPlainRef() [Fact] public void TryApplyBundle_SentinelNotAllowed_KeepsSentinel() { - var bundle = new Bundle - { - Entries = - [ - new() - { - Title = "t", - Prs = ["# PRIVATE: https://github.com/elastic/other/pull/1"] - } - ] - }; + var bundle = new Bundle { Entries = [new() { Title = "t", Prs = ["# PRIVATE: https://github.com/elastic/other/pull/1"] }] }; var ok = LinkAllowlistSanitizer.TryApplyBundle( Collector, @@ -295,7 +242,8 @@ public void TryApplyBundle_SentinelNotAllowed_KeepsSentinel() "elastic", "elasticsearch", out var sanitized, - out var changed); + out var changed + ); ok.Should().BeTrue(); changed.Should().BeFalse(); @@ -313,8 +261,7 @@ public void EmitAssemblerDiagnostics_MissingRepo_EmitsWarning() [Fact] public void EmitAssemblerDiagnostics_PrivateRepo_EmitsWarning() { - var yaml = - """ + var yaml = """ references: elastic/foo: private: true @@ -365,7 +312,11 @@ public void FormatPrLinkAsciidoc_Sentinel_ReturnsEmpty() [Fact] public void FormatIssueLinkAsciidoc_Sentinel_ReturnsEmpty() { - var s = ChangelogTextUtilities.FormatIssueLinkAsciidoc("# PRIVATE: https://github.com/elastic/x/issues/1", "x", hidePrivateLinks: false); + var s = ChangelogTextUtilities.FormatIssueLinkAsciidoc( + "# PRIVATE: https://github.com/elastic/x/issues/1", + "x", + hidePrivateLinks: false + ); s.Should().BeEmpty(); } @@ -420,8 +371,7 @@ public void BuildAllowReposFromAssembler_PublicSkipRepo_IsAllowed() [Fact] public void BuildAllowReposFromAssembler_DefaultsOwnerToElastic() { - var yaml = - """ + var yaml = """ references: beats: {} """; @@ -454,8 +404,14 @@ public void TryApplyChangelogEntry_ScrubsPrsAndIssues() var allow = new[] { "elastic/elasticsearch" }; var ok = LinkAllowlistSanitizer.TryApplyChangelogEntry( - Collector, entry, allow, "elastic", "elasticsearch", - out var sanitized, out var changed); + Collector, + entry, + allow, + "elastic", + "elasticsearch", + out var sanitized, + out var changed + ); ok.Should().BeTrue(); changed.Should().BeTrue(); @@ -474,8 +430,14 @@ public void TryApplyChangelogEntry_ScrubsDescriptionText() var allow = new[] { "elastic/elasticsearch" }; var ok = LinkAllowlistSanitizer.TryApplyChangelogEntry( - Collector, entry, allow, "elastic", "elasticsearch", - out var sanitized, out var changed); + Collector, + entry, + allow, + "elastic", + "elasticsearch", + out var sanitized, + out var changed + ); ok.Should().BeTrue(); changed.Should().BeTrue(); @@ -495,8 +457,14 @@ public void TryApplyChangelogEntry_ScrubsImpactAndAction() var allow = new[] { "elastic/elasticsearch" }; var ok = LinkAllowlistSanitizer.TryApplyChangelogEntry( - Collector, entry, allow, "elastic", "elasticsearch", - out var sanitized, out var changed); + Collector, + entry, + allow, + "elastic", + "elasticsearch", + out var sanitized, + out var changed + ); ok.Should().BeTrue(); changed.Should().BeTrue(); @@ -517,8 +485,14 @@ public void TryApplyChangelogEntry_AllAllowed_NoChanges() var allow = new[] { "elastic/elasticsearch", "elastic/kibana" }; var ok = LinkAllowlistSanitizer.TryApplyChangelogEntry( - Collector, entry, allow, "elastic", "elasticsearch", - out var sanitized, out var changed); + Collector, + entry, + allow, + "elastic", + "elasticsearch", + out var sanitized, + out var changed + ); ok.Should().BeTrue(); changed.Should().BeFalse(); @@ -542,8 +516,14 @@ public void TryApplyChangelogEntry_NullFields_PreservesNulls() var allow = new[] { "elastic/elasticsearch" }; var ok = LinkAllowlistSanitizer.TryApplyChangelogEntry( - Collector, entry, allow, "elastic", "elasticsearch", - out var sanitized, out var changed); + Collector, + entry, + allow, + "elastic", + "elasticsearch", + out var sanitized, + out var changed + ); ok.Should().BeTrue(); changed.Should().BeFalse(); @@ -560,17 +540,18 @@ public void TryApplyChangelogEntry_BarePrNumberWithoutDefaultRepo_KeptWithWarnin // numeric PR ref ("155500") must be tolerated rather than failing the whole entry — the // reference carries no repo identity so it cannot leak a private link, and downstream // rendering supplies the owner/repo from runtime context. - var entry = new BundledEntry - { - Title = "Fork PR entry", - Prs = ["155500"], - Issues = null - }; + var entry = new BundledEntry { Title = "Fork PR entry", Prs = ["155500"], Issues = null }; var allow = new[] { "elastic/elasticsearch" }; var ok = LinkAllowlistSanitizer.TryApplyChangelogEntry( - Collector, entry, allow, "elastic", null, - out var sanitized, out var changed); + Collector, + entry, + allow, + "elastic", + null, + out var sanitized, + out var changed + ); ok.Should().BeTrue(); changed.Should().BeFalse(); @@ -582,17 +563,18 @@ public void TryApplyChangelogEntry_BarePrNumberWithoutDefaultRepo_KeptWithWarnin [Fact] public void TryApplyChangelogEntry_BareIssueNumberWithoutDefaultRepo_KeptWithWarning() { - var entry = new BundledEntry - { - Title = "Entry with bare issue", - Prs = null, - Issues = ["4274"] - }; + var entry = new BundledEntry { Title = "Entry with bare issue", Prs = null, Issues = ["4274"] }; var allow = new[] { "elastic/elasticsearch" }; var ok = LinkAllowlistSanitizer.TryApplyChangelogEntry( - Collector, entry, allow, "elastic", null, - out var sanitized, out var changed); + Collector, + entry, + allow, + "elastic", + null, + out var sanitized, + out var changed + ); ok.Should().BeTrue(); changed.Should().BeFalse(); @@ -606,17 +588,10 @@ public void TryApplyChangelogEntry_UnparseableNonNumericRef_StillErrors() { // Genuinely unparseable references (not bare numbers and not URL/short-form) should still // fail-closed so we surface schema regressions instead of silently dropping data. - var entry = new BundledEntry - { - Title = "Malformed entry", - Prs = ["not-a-pr-ref"], - Issues = null - }; + var entry = new BundledEntry { Title = "Malformed entry", Prs = ["not-a-pr-ref"], Issues = null }; var allow = new[] { "elastic/elasticsearch" }; - var ok = LinkAllowlistSanitizer.TryApplyChangelogEntry( - Collector, entry, allow, "elastic", "elasticsearch", - out _, out _); + var ok = LinkAllowlistSanitizer.TryApplyChangelogEntry(Collector, entry, allow, "elastic", "elasticsearch", out _, out _); ok.Should().BeFalse(); Collector.Errors.Should().BeGreaterThan(0); @@ -631,7 +606,8 @@ public void ScrubText_ReplacesPrivateGitHubUrl() var result = LinkAllowlistSanitizer.ScrubText( "See https://github.com/elastic/secret-repo/pull/42 for details", AllowElasticsearch, - ref changed); + ref changed + ); changed.Should().BeTrue(); result.Should().NotContain("secret-repo"); @@ -642,10 +618,7 @@ public void ScrubText_ReplacesPrivateGitHubUrl() public void ScrubText_ReplacesPrivateShortForm() { var changed = false; - var result = LinkAllowlistSanitizer.ScrubText( - "Related to elastic/private-team#99", - AllowElasticsearch, - ref changed); + var result = LinkAllowlistSanitizer.ScrubText("Related to elastic/private-team#99", AllowElasticsearch, ref changed); changed.Should().BeTrue(); result.Should().NotContain("private-team"); @@ -658,7 +631,8 @@ public void ScrubText_PreservesAllowedReferences() var result = LinkAllowlistSanitizer.ScrubText( "Fixed in https://github.com/elastic/elasticsearch/pull/100 and elastic/kibana#50", AllowElasticsearchAndKibana, - ref changed); + ref changed + ); changed.Should().BeFalse(); result.Should().Contain("https://github.com/elastic/elasticsearch/pull/100"); @@ -669,10 +643,7 @@ public void ScrubText_PreservesAllowedReferences() public void ScrubText_NullInput_ReturnsNull() { var changed = false; - var result = LinkAllowlistSanitizer.ScrubText( - null, - AllowElasticsearch, - ref changed); + var result = LinkAllowlistSanitizer.ScrubText(null, AllowElasticsearch, ref changed); changed.Should().BeFalse(); result.Should().BeNull(); @@ -682,10 +653,7 @@ public void ScrubText_NullInput_ReturnsNull() public void ScrubText_EmptyInput_ReturnsEmpty() { var changed = false; - var result = LinkAllowlistSanitizer.ScrubText( - "", - AllowElasticsearch, - ref changed); + var result = LinkAllowlistSanitizer.ScrubText("", AllowElasticsearch, ref changed); changed.Should().BeFalse(); result.Should().BeEmpty(); @@ -695,10 +663,7 @@ public void ScrubText_EmptyInput_ReturnsEmpty() public void ScrubText_NoReferences_ReturnsUnchanged() { var changed = false; - var result = LinkAllowlistSanitizer.ScrubText( - "This is plain text with no GitHub references.", - AllowElasticsearch, - ref changed); + var result = LinkAllowlistSanitizer.ScrubText("This is plain text with no GitHub references.", AllowElasticsearch, ref changed); changed.Should().BeFalse(); result.Should().Be("This is plain text with no GitHub references."); @@ -711,7 +676,8 @@ public void ScrubText_MixedReferences_ScrubsOnlyPrivate() var result = LinkAllowlistSanitizer.ScrubText( "Public elastic/elasticsearch#1 and private elastic/secret#2", AllowElasticsearch, - ref changed); + ref changed + ); changed.Should().BeTrue(); result.Should().Contain("elastic/elasticsearch#1"); @@ -732,13 +698,17 @@ public void TryApplyChangelogEntry_Idempotent_SecondPassNoChanges() var allow = new[] { "elastic/elasticsearch" }; - LinkAllowlistSanitizer.TryApplyChangelogEntry( - Collector, entry, allow, "elastic", "elasticsearch", - out var firstPass, out _); + LinkAllowlistSanitizer.TryApplyChangelogEntry(Collector, entry, allow, "elastic", "elasticsearch", out var firstPass, out _); var ok = LinkAllowlistSanitizer.TryApplyChangelogEntry( - Collector, firstPass, allow, "elastic", "elasticsearch", - out var secondPass, out var secondChanged); + Collector, + firstPass, + allow, + "elastic", + "elasticsearch", + out var secondPass, + out var secondChanged + ); ok.Should().BeTrue(); secondChanged.Should().BeFalse(); @@ -750,16 +720,10 @@ public void TryApplyChangelogEntry_Idempotent_SecondPassNoChanges() public void ScrubText_Idempotent_SecondPassUnchanged() { var changed1 = false; - var result1 = LinkAllowlistSanitizer.ScrubText( - "See elastic/secret#1 for details", - AllowElasticsearch, - ref changed1); + var result1 = LinkAllowlistSanitizer.ScrubText("See elastic/secret#1 for details", AllowElasticsearch, ref changed1); var changed2 = false; - var result2 = LinkAllowlistSanitizer.ScrubText( - result1, - AllowElasticsearch, - ref changed2); + var result2 = LinkAllowlistSanitizer.ScrubText(result1, AllowElasticsearch, ref changed2); changed2.Should().BeFalse(); result2.Should().Be(result1); @@ -772,7 +736,8 @@ public void ScrubText_IssueUrl_ReplacesPrivate() var result = LinkAllowlistSanitizer.ScrubText( "Relates to https://github.com/elastic/secret-repo/issues/99", AllowElasticsearch, - ref changed); + ref changed + ); changed.Should().BeTrue(); result.Should().NotContain("secret-repo"); @@ -798,8 +763,14 @@ public void ScrubBundleForPublic_DropsPrivateRefsDirectly() }; var ok = LinkAllowlistSanitizer.ScrubBundleForPublic( - Collector, bundle, AllowElasticsearch, "elastic", "elasticsearch", - out var sanitized, out var changed); + Collector, + bundle, + AllowElasticsearch, + "elastic", + "elasticsearch", + out var sanitized, + out var changed + ); ok.Should().BeTrue(); changed.Should().BeTrue(); @@ -815,20 +786,18 @@ public void ScrubBundleForPublic_AllPrivate_ReturnsEmptyLists() { var bundle = new Bundle { - Entries = - [ - new() - { - Title = "All private", - Prs = ["https://github.com/elastic/secret/pull/1"], - Issues = ["elastic/secret#2"] - } - ] + Entries = [new() { Title = "All private", Prs = ["https://github.com/elastic/secret/pull/1"], Issues = ["elastic/secret#2"] }] }; var ok = LinkAllowlistSanitizer.ScrubBundleForPublic( - Collector, bundle, AllowElasticsearch, "elastic", "elasticsearch", - out var sanitized, out _); + Collector, + bundle, + AllowElasticsearch, + "elastic", + "elasticsearch", + out var sanitized, + out _ + ); ok.Should().BeTrue(); sanitized.Entries[0].Prs.Should().BeEmpty(); @@ -838,14 +807,17 @@ public void ScrubBundleForPublic_AllPrivate_ReturnsEmptyLists() [Fact] public void ScrubBundleForPublic_NullLists_PreservesNull() { - var bundle = new Bundle - { - Entries = [new() { Title = "No refs", Prs = null, Issues = null }] - }; + var bundle = new Bundle { Entries = [new() { Title = "No refs", Prs = null, Issues = null }] }; var ok = LinkAllowlistSanitizer.ScrubBundleForPublic( - Collector, bundle, AllowElasticsearch, "elastic", "elasticsearch", - out var sanitized, out var changed); + Collector, + bundle, + AllowElasticsearch, + "elastic", + "elasticsearch", + out var sanitized, + out var changed + ); ok.Should().BeTrue(); changed.Should().BeFalse(); @@ -866,8 +838,14 @@ public void ScrubBundleForPublic_MultipleEntries_ScrubsAll() }; var ok = LinkAllowlistSanitizer.ScrubBundleForPublic( - Collector, bundle, AllowElasticsearch, "elastic", "elasticsearch", - out var sanitized, out _); + Collector, + bundle, + AllowElasticsearch, + "elastic", + "elasticsearch", + out var sanitized, + out _ + ); ok.Should().BeTrue(); sanitized.Entries[0].Prs.Should().BeEmpty(); @@ -880,20 +858,18 @@ public void ScrubBundleForPublic_NeverProducesSentinels() { var bundle = new Bundle { - Entries = - [ - new() - { - Title = "Entry", - Prs = ["https://github.com/elastic/secret/pull/1"], - Description = "See elastic/secret#2" - } - ] + Entries = [new() { Title = "Entry", Prs = ["https://github.com/elastic/secret/pull/1"], Description = "See elastic/secret#2" }] }; var ok = LinkAllowlistSanitizer.ScrubBundleForPublic( - Collector, bundle, AllowElasticsearch, "elastic", "elasticsearch", - out var sanitized, out _); + Collector, + bundle, + AllowElasticsearch, + "elastic", + "elasticsearch", + out var sanitized, + out _ + ); ok.Should().BeTrue(); sanitized.Entries[0].Prs.Should().NotContain(r => r.Contains("# PRIVATE:")); @@ -904,15 +880,17 @@ public void ScrubBundleForPublic_NeverProducesSentinels() [Fact] public void ScrubBundleForPublic_ScrubsBundleDescription() { - var bundle = new Bundle - { - Description = "Release notes referencing elastic/secret#42", - Entries = [new() { Title = "Entry" }] - }; + var bundle = new Bundle { Description = "Release notes referencing elastic/secret#42", Entries = [new() { Title = "Entry" }] }; var ok = LinkAllowlistSanitizer.ScrubBundleForPublic( - Collector, bundle, AllowElasticsearch, "elastic", "elasticsearch", - out var sanitized, out var changed); + Collector, + bundle, + AllowElasticsearch, + "elastic", + "elasticsearch", + out var sanitized, + out var changed + ); ok.Should().BeTrue(); changed.Should().BeTrue(); @@ -924,7 +902,8 @@ public void ScrubBundleForPublic_ScrubsBundleDescription() [Fact] public void ValidateNoPrivateReferences_CleanYaml_DoesNotThrow() { - var yaml = """ + var yaml = + """ title: Feature prs: - https://github.com/elastic/elasticsearch/pull/1 @@ -945,8 +924,7 @@ public void ValidateNoPrivateReferences_PrivateUrl_Throws() """; var act = () => LinkAllowlistSanitizer.ValidateNoPrivateReferences(yaml, AllowElasticsearch); - act.Should().Throw() - .WithMessage("*secret-repo*"); + act.Should().Throw().WithMessage("*secret-repo*"); } [Fact] @@ -957,8 +935,7 @@ public void ValidateNoPrivateReferences_PrivateShortForm_Throws() """; var act = () => LinkAllowlistSanitizer.ValidateNoPrivateReferences(yaml, AllowElasticsearch); - act.Should().Throw() - .WithMessage("*secret-team*"); + act.Should().Throw().WithMessage("*secret-team*"); } [Fact] @@ -970,8 +947,7 @@ public void ValidateNoPrivateReferences_ResidualSentinel_Throws() """; var act = () => LinkAllowlistSanitizer.ValidateNoPrivateReferences(yaml, AllowElasticsearch); - act.Should().Throw() - .WithMessage("*PRIVATE*"); + act.Should().Throw().WithMessage("*PRIVATE*"); } [Fact] @@ -996,8 +972,7 @@ public void ValidateNoPrivateReferences_MixedAllowedAndPrivate_Throws() var yaml = "Public https://github.com/elastic/elasticsearch/pull/1 and private https://github.com/elastic/secret/issues/2"; var act = () => LinkAllowlistSanitizer.ValidateNoPrivateReferences(yaml, AllowElasticsearch); - act.Should().Throw() - .WithMessage("*secret*"); + act.Should().Throw().WithMessage("*secret*"); } // --- TryApplyChangelogEntry mixed scenarios --- @@ -1008,7 +983,8 @@ public void TryApplyChangelogEntry_MixedPrs_KeepsAllowedDropsPrivate() var entry = new BundledEntry { Title = "Mixed refs", - Prs = [ + Prs = + [ "https://github.com/elastic/elasticsearch/pull/1", "https://github.com/elastic/secret-repo/pull/2", "https://github.com/elastic/kibana/pull/3" @@ -1016,8 +992,14 @@ public void TryApplyChangelogEntry_MixedPrs_KeepsAllowedDropsPrivate() }; var ok = LinkAllowlistSanitizer.TryApplyChangelogEntry( - Collector, entry, AllowElasticsearchAndKibana, "elastic", "elasticsearch", - out var sanitized, out var changed); + Collector, + entry, + AllowElasticsearchAndKibana, + "elastic", + "elasticsearch", + out var sanitized, + out var changed + ); ok.Should().BeTrue(); changed.Should().BeTrue(); @@ -1041,8 +1023,14 @@ public void TryApplyChangelogEntry_PrivateRefsInAllFields_ScrubsEverything() }; var ok = LinkAllowlistSanitizer.TryApplyChangelogEntry( - Collector, entry, AllowElasticsearch, "elastic", "elasticsearch", - out var sanitized, out _); + Collector, + entry, + AllowElasticsearch, + "elastic", + "elasticsearch", + out var sanitized, + out _ + ); ok.Should().BeTrue(); sanitized.Prs.Should().BeEmpty(); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/RemoveReleaseVersionTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/RemoveReleaseVersionTests.cs index f0dcdefd2d..1107e8d8f9 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/RemoveReleaseVersionTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/RemoveReleaseVersionTests.cs @@ -38,7 +38,8 @@ public RemoveReleaseVersionTests(ITestOutputHelper output) : base(output) public async Task ReleaseVersion_RemovesMatchingChangelogs() { // Arrange – two changelog files each referencing a specific PR - await WriteChangelog("pr-12345.yaml", + await WriteChangelog( + "pr-12345.yaml", """ title: Fix query parsing type: bug-fix @@ -48,9 +49,11 @@ await WriteChangelog("pr-12345.yaml", lifecycle: ga prs: - https://github.com/elastic/elasticsearch/pull/12345 - """); + """ + ); - await WriteChangelog("pr-12346.yaml", + await WriteChangelog( + "pr-12346.yaml", """ title: New aggregation API type: feature @@ -60,10 +63,12 @@ await WriteChangelog("pr-12346.yaml", lifecycle: ga prs: - https://github.com/elastic/elasticsearch/pull/12346 - """); + """ + ); // A third file whose PR is NOT in the release — must not be removed - await WriteChangelog("pr-99999.yaml", + await WriteChangelog( + "pr-99999.yaml", """ title: Unrelated change type: feature @@ -73,7 +78,8 @@ await WriteChangelog("pr-99999.yaml", lifecycle: ga prs: - https://github.com/elastic/elasticsearch/pull/99999 - """); + """ + ); // Release body references only the first two PRs var releaseBody = @@ -86,16 +92,13 @@ await WriteChangelog("pr-99999.yaml", **Full Changelog**: https://github.com/elastic/elasticsearch/compare/v9.1.0...v9.2.0 """; - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._)) - .Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._) + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); // Act – simulate what the command does: fetch release → build PR URL list → call service var prUrls = await ResolveReleasePrUrls("elastic", "elasticsearch", "v9.2.0"); - var input = new ChangelogRemoveArguments - { - Directory = _changelogDir, - Prs = prUrls - }; + var input = new ChangelogRemoveArguments { Directory = _changelogDir, Prs = prUrls }; var result = await _removeService.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -113,7 +116,8 @@ await WriteChangelog("pr-99999.yaml", public async Task ReleaseVersion_DryRun_DoesNotDeleteFiles() { // Arrange - await WriteChangelog("pr-12345.yaml", + await WriteChangelog( + "pr-12345.yaml", """ title: Fix query parsing type: bug-fix @@ -123,20 +127,17 @@ await WriteChangelog("pr-12345.yaml", lifecycle: ga prs: - https://github.com/elastic/elasticsearch/pull/12345 - """); + """ + ); var releaseBody = "* Fix query parsing by @user in #12345\n"; - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._)) - .Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._) + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); var prUrls = await ResolveReleasePrUrls("elastic", "elasticsearch", "v9.2.0"); - var input = new ChangelogRemoveArguments - { - Directory = _changelogDir, - Prs = prUrls, - DryRun = true - }; + var input = new ChangelogRemoveArguments { Directory = _changelogDir, Prs = prUrls, DryRun = true }; // Act var result = await _removeService.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -144,8 +145,7 @@ await WriteChangelog("pr-12345.yaml", // Assert – file must still exist after a dry run result.Should().BeTrue(); Collector.Errors.Should().Be(0); - FileSystem.File.Exists(FileSystem.Path.Join(_changelogDir, "pr-12345.yaml")) - .Should().BeTrue("dry run must not delete files"); + FileSystem.File.Exists(FileSystem.Path.Join(_changelogDir, "pr-12345.yaml")).Should().BeTrue("dry run must not delete files"); } // ----------------------------------------------------------------------- @@ -156,16 +156,13 @@ await WriteChangelog("pr-12345.yaml", public async Task ReleaseVersion_WithNoMatchingPrs_EmitsWarning() { // Arrange – release body has no PR references - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._)) - .Returns(new GitHubReleaseInfo - { - TagName = "v9.2.0", - Name = "9.2.0", - Body = "Release notes with no pull request references." - }); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._) + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = "Release notes with no pull request references." }); // Act – replicate command logic: parse, detect zero refs, warn and return without deleting - var release = await _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", TestContext.Current.CancellationToken); + var release = + await _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", TestContext.Current.CancellationToken); var parsed = ReleaseNoteParser.Parse(release!.Body); // Assert – the parser found nothing, so the command would emit a warning and exit early @@ -180,11 +177,13 @@ public async Task ReleaseVersion_WithNoMatchingPrs_EmitsWarning() public async Task ReleaseVersion_FetchFailure_ReturnsNull() { // Arrange - A.CallTo(() => _mockReleaseService.FetchReleaseAsync(A._, A._, A._, A._)) - .Returns((GitHubReleaseInfo?)null); + A.CallTo(() => _mockReleaseService.FetchReleaseAsync(A._, A._, A._, A._)).Returns( + (GitHubReleaseInfo?)null + ); // Act - var release = await _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", TestContext.Current.CancellationToken); + var release = + await _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", TestContext.Current.CancellationToken); // Assert – command returns error on null release release.Should().BeNull(); @@ -198,20 +197,17 @@ public async Task ReleaseVersion_FetchFailure_ReturnsNull() public async Task ReleaseVersion_Latest_CallsFetchWithLatestTag() { // Arrange - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "latest", A._)) - .Returns(new GitHubReleaseInfo - { - TagName = "v9.2.0", - Name = "9.2.0", - Body = "No PR references." - }); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "latest", A._) + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = "No PR references." }); // Act _ = await _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "latest", TestContext.Current.CancellationToken); // Assert - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "latest", A._)) - .MustHaveHappenedOnceExactly(); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "latest", A._) + ).MustHaveHappenedOnceExactly(); } // ----------------------------------------------------------------------- @@ -222,7 +218,8 @@ public async Task ReleaseVersion_Latest_CallsFetchWithLatestTag() public async Task ReleaseVersion_OnlyRemovesChangelogsMatchingReleasePrs() { // Arrange – three changelogs; release only references two - await WriteChangelog("es-pr-100.yaml", + await WriteChangelog( + "es-pr-100.yaml", """ title: Feature A type: feature @@ -232,9 +229,11 @@ await WriteChangelog("es-pr-100.yaml", lifecycle: ga prs: - https://github.com/elastic/elasticsearch/pull/100 - """); + """ + ); - await WriteChangelog("es-pr-200.yaml", + await WriteChangelog( + "es-pr-200.yaml", """ title: Feature B type: feature @@ -244,9 +243,11 @@ await WriteChangelog("es-pr-200.yaml", lifecycle: ga prs: - https://github.com/elastic/elasticsearch/pull/200 - """); + """ + ); - await WriteChangelog("es-pr-300.yaml", + await WriteChangelog( + "es-pr-300.yaml", """ title: Feature C (different release) type: feature @@ -256,24 +257,21 @@ await WriteChangelog("es-pr-300.yaml", lifecycle: ga prs: - https://github.com/elastic/elasticsearch/pull/300 - """); + """ + ); // Release only contains PRs 100 and 200 - var releaseBody = - """ + var releaseBody = """ * Feature A by @user in #100 * Feature B by @user in #200 """; - A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._)) - .Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); + A.CallTo( + () => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A._) + ).Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody }); var prUrls = await ResolveReleasePrUrls("elastic", "elasticsearch", "v9.2.0"); - var input = new ChangelogRemoveArguments - { - Directory = _changelogDir, - Prs = prUrls - }; + var input = new ChangelogRemoveArguments { Directory = _changelogDir, Prs = prUrls }; // Act var result = await _removeService.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -301,8 +299,6 @@ private async Task ResolveReleasePrUrls(string owner, string repo, str { var release = await _mockReleaseService.FetchReleaseAsync(owner, repo, version, TestContext.Current.CancellationToken); var parsed = ReleaseNoteParser.Parse(release!.Body); - return parsed.PrReferences - .Select(r => $"https://github.com/{owner}/{repo}/pull/{r.PrNumber}") - .ToArray(); + return parsed.PrReferences.Select(r => $"https://github.com/{owner}/{repo}/pull/{r.PrNumber}").ToArray(); } } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/BasicRenderTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/BasicRenderTests.cs index 21c0c061ea..1917442476 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/BasicRenderTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/BasicRenderTests.cs @@ -32,8 +32,7 @@ public async Task RenderChangelogs_WithValidBundle_CreatesMarkdownFiles() FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 9.2.0 @@ -112,16 +111,17 @@ public async Task RenderChangelogs_WithMultipleTypes_DoesNotIncludeCrossFileLink FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 9.3.0 """; - var bundleContent = CreateResolvedBundleContent(bundleHeader, + var bundleContent = CreateResolvedBundleContent( + bundleHeader, ("feature.yaml", featureChangelog), ("deprecation.yaml", deprecationChangelog), - ("highlight.yaml", highlightChangelog)); + ("highlight.yaml", highlightChangelog) + ); await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); @@ -198,8 +198,7 @@ public async Task RenderChangelogs_WithMultipleBundles_MergesAndRenders() FileSystem.Directory.CreateDirectory(bundleDir); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 9.2.0 @@ -217,11 +216,7 @@ public async Task RenderChangelogs_WithMultipleBundles_MergesAndRenders() var input = new RenderChangelogsArguments { - Bundles = - [ - new BundleInput { BundleFile = bundle1 }, - new BundleInput { BundleFile = bundle2 } - ], + Bundles = [new BundleInput { BundleFile = bundle1 }, new BundleInput { BundleFile = bundle2 }], Output = outputDir, Title = "9.2.0" }; diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/BundleValidationTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/BundleValidationTests.cs index 3335b8ad92..f2db6c1d9f 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/BundleValidationTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/BundleValidationTests.cs @@ -11,8 +11,7 @@ namespace Elastic.Changelog.Tests.Changelogs.Render; public class BundleValidationTests(ITestOutputHelper output) : RenderChangelogTestBase(output) { // language=yaml - private const string BundleHeader = - """ + private const string BundleHeader = """ products: - product: elasticsearch target: 9.2.0 @@ -61,16 +60,13 @@ public async Task MultipleAmendFiles_AllEntriesMergedAndRendered() var bundleDir = CreateBundleDir(); var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); - await WriteBundleAsync(bundleFile, - CreateResolvedBundleContent(BundleHeader, ("1000000001-feature.yaml", ChangelogFeature1))); + await WriteBundleAsync(bundleFile, CreateResolvedBundleContent(BundleHeader, ("1000000001-feature.yaml", ChangelogFeature1))); var amend1 = FileSystem.Path.Join(bundleDir, "bundle.amend-1.yaml"); - await WriteBundleAsync(amend1, - CreateResolvedBundleContent(BundleHeader, ("1000000002-enhancement.yaml", ChangelogFeature2))); + await WriteBundleAsync(amend1, CreateResolvedBundleContent(BundleHeader, ("1000000002-enhancement.yaml", ChangelogFeature2))); var amend2 = FileSystem.Path.Join(bundleDir, "bundle.amend-2.yaml"); - await WriteBundleAsync(amend2, - CreateResolvedBundleContent(BundleHeader, ("1000000003-bugfix.yaml", ChangelogFeature3))); + await WriteBundleAsync(amend2, CreateResolvedBundleContent(BundleHeader, ("1000000003-bugfix.yaml", ChangelogFeature3))); var input = CreateRenderInput(bundleFile); @@ -100,8 +96,7 @@ public async Task AmendFileEntry_StaleProvenanceChecksum_NoWarning() var bundleDir = CreateBundleDir(); var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); - await WriteBundleAsync(bundleFile, - CreateResolvedBundleContent(BundleHeader, ("1000000001-feature.yaml", ChangelogFeature1))); + await WriteBundleAsync(bundleFile, CreateResolvedBundleContent(BundleHeader, ("1000000001-feature.yaml", ChangelogFeature1))); var amend1 = FileSystem.Path.Join(bundleDir, "bundle.amend-1.yaml"); // language=yaml @@ -145,8 +140,7 @@ public async Task AmendFileEntry_WithInlineContent_MergedAndRendered() var bundleDir = CreateBundleDir(); var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); - await WriteBundleAsync(bundleFile, - CreateResolvedBundleContent(BundleHeader, ("1000000001-feature.yaml", ChangelogFeature1))); + await WriteBundleAsync(bundleFile, CreateResolvedBundleContent(BundleHeader, ("1000000001-feature.yaml", ChangelogFeature1))); var amend1 = FileSystem.Path.Join(bundleDir, "bundle.amend-1.yaml"); // language=yaml @@ -187,11 +181,10 @@ public async Task ExcludeAmendFile_OmitsEntryFromRenderedOutput() var file2 = "1000000002-enhancement.yaml"; var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); - await WriteBundleAsync(bundleFile, - CreateResolvedBundleContent( - BundleHeader, - ("1000000001-feature.yaml", ChangelogFeature1), - (file2, ChangelogFeature2))); + await WriteBundleAsync( + bundleFile, + CreateResolvedBundleContent(BundleHeader, ("1000000001-feature.yaml", ChangelogFeature1), (file2, ChangelogFeature2)) + ); var amend1 = FileSystem.Path.Join(bundleDir, "bundle.amend-1.yaml"); await FileSystem.File.WriteAllTextAsync( @@ -203,7 +196,8 @@ await FileSystem.File.WriteAllTextAsync( name: {file2} checksum: {ComputeSha1(ChangelogFeature2)} """, - TestContext.Current.CancellationToken); + TestContext.Current.CancellationToken + ); var input = CreateRenderInput(bundleFile); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/ChecksumValidationTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/ChecksumValidationTests.cs index 75796ca906..c2148f3300 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/ChecksumValidationTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/ChecksumValidationTests.cs @@ -11,8 +11,7 @@ namespace Elastic.Changelog.Tests.Changelogs.Render; public class ChecksumValidationTests(ITestOutputHelper output) : RenderChangelogTestBase(output) { // language=yaml - private const string BundleHeader = - """ + private const string BundleHeader = """ products: - product: elasticsearch target: 9.2.0 @@ -85,7 +84,6 @@ public void Checksums_WithAndWithoutComments_AreEqual() var withComments = ComputeSha1(ChangelogWithComments); var withoutComments = ComputeSha1(ChangelogWithoutComments); - withComments.Should().Be(withoutComments, - "checksum should be identical regardless of comment lines"); + withComments.Should().Be(withoutComments, "checksum should be identical regardless of comment lines"); } } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/DescriptionVisibilityTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/DescriptionVisibilityTests.cs index 0ceea3f34c..443a2ef742 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/DescriptionVisibilityTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/DescriptionVisibilityTests.cs @@ -35,8 +35,7 @@ public async Task RenderChangelogs_DefaultBehavior_IncludesDescriptionsInMarkdow FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 9.2.0 @@ -51,7 +50,8 @@ public async Task RenderChangelogs_DefaultBehavior_IncludesDescriptionsInMarkdow Bundles = [new BundleInput { BundleFile = bundleFile, Repo = "elasticsearch" }], Output = outputDir, FileType = ChangelogFileType.Markdown, - HideDescriptions = false // Default behavior + HideDescriptions = + false // Default behavior }; // Act @@ -93,8 +93,7 @@ public async Task RenderChangelogs_NoDescriptionsFlag_HidesDescriptionsInMarkdow FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 9.2.0 @@ -109,7 +108,8 @@ public async Task RenderChangelogs_NoDescriptionsFlag_HidesDescriptionsInMarkdow Bundles = [new BundleInput { BundleFile = bundleFile, Repo = "elasticsearch" }], Output = outputDir, FileType = ChangelogFileType.Markdown, - HideDescriptions = true // Hide descriptions + HideDescriptions = + true // Hide descriptions }; // Act @@ -155,8 +155,7 @@ public async Task RenderChangelogs_NoDescriptionsFlag_HidesDescriptionsInAsciido FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 9.2.0 @@ -171,7 +170,8 @@ public async Task RenderChangelogs_NoDescriptionsFlag_HidesDescriptionsInAsciido Bundles = [new BundleInput { BundleFile = bundleFile, Repo = "elasticsearch" }], Output = outputDir, FileType = ChangelogFileType.Asciidoc, - HideDescriptions = true // Hide descriptions + HideDescriptions = + true // Hide descriptions }; // Act @@ -219,8 +219,7 @@ public async Task RenderChangelogs_NoDescriptionsFlag_PreservesImpactAndActionFo FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 9.2.0 @@ -236,7 +235,8 @@ public async Task RenderChangelogs_NoDescriptionsFlag_PreservesImpactAndActionFo Output = outputDir, FileType = ChangelogFileType.Markdown, HideDescriptions = true, - Dropdowns = false // Test flattened mode + Dropdowns = + false // Test flattened mode }; // Act @@ -288,8 +288,7 @@ public async Task RenderChangelogs_NoDescriptionsFlag_WorksWithDropdownsMode() FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 9.2.0 @@ -305,7 +304,8 @@ public async Task RenderChangelogs_NoDescriptionsFlag_WorksWithDropdownsMode() Output = outputDir, FileType = ChangelogFileType.Markdown, HideDescriptions = true, - Dropdowns = true // Test dropdown mode + Dropdowns = + true // Test dropdown mode }; // Act diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/DropdownRenderTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/DropdownRenderTests.cs index e3ebc46799..d758f1b5d1 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/DropdownRenderTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/DropdownRenderTests.cs @@ -36,8 +36,7 @@ public async Task RenderChangelogs_WithDropdownsTrue_RendersDropdownFormat() FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 9.2.0 @@ -102,8 +101,7 @@ public async Task RenderChangelogs_WithDropdownsFalse_RendersFlattendFormat() FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 9.2.0 @@ -118,7 +116,8 @@ public async Task RenderChangelogs_WithDropdownsFalse_RendersFlattendFormat() Bundles = [new BundleInput { BundleFile = bundleFile }], Output = outputDir, Title = "9.2.0", - Dropdowns = false // Explicitly set to false for clarity + Dropdowns = + false // Explicitly set to false for clarity }; // Act @@ -172,8 +171,7 @@ public async Task RenderChangelogs_DefaultDropdownsFalse_RendersFlattedFormat() FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 9.2.0 @@ -240,8 +238,7 @@ public async Task RenderChangelogs_HighlightsWithDropdowns_RendersCorrectFormat( FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 9.2.0 @@ -252,11 +249,7 @@ public async Task RenderChangelogs_HighlightsWithDropdowns_RendersCorrectFormat( var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); // Test both dropdown and flattened modes - var testCases = new[] - { - new { Dropdowns = true, ExpectDropdown = true }, - new { Dropdowns = false, ExpectDropdown = false } - }; + var testCases = new[] { new { Dropdowns = true, ExpectDropdown = true }, new { Dropdowns = false, ExpectDropdown = false } }; foreach (var testCase in testCases) { @@ -329,8 +322,7 @@ public async Task RenderChangelogs_AsciidocFormat_IgnoresDropdownsFlag() FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 9.2.0 diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/DuplicateHandlingTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/DuplicateHandlingTests.cs index b309623268..12a15b9c15 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/DuplicateHandlingTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/DuplicateHandlingTests.cs @@ -36,8 +36,7 @@ public async Task RenderChangelogs_WithDuplicateFileName_EmitsWarning() FileSystem.Directory.CreateDirectory(bundleDir); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 9.2.0 @@ -55,11 +54,7 @@ public async Task RenderChangelogs_WithDuplicateFileName_EmitsWarning() var input = new RenderChangelogsArguments { - Bundles = - [ - new BundleInput { BundleFile = bundle1 }, - new BundleInput { BundleFile = bundle2 } - ], + Bundles = [new BundleInput { BundleFile = bundle1 }, new BundleInput { BundleFile = bundle2 }], Output = outputDir }; @@ -70,9 +65,7 @@ public async Task RenderChangelogs_WithDuplicateFileName_EmitsWarning() result.Should().BeTrue(); Collector.Errors.Should().Be(0); Collector.Warnings.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Warning && - d.Message.Contains("appears in multiple bundles")); + Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Warning && d.Message.Contains("appears in multiple bundles")); } [Fact] @@ -99,8 +92,7 @@ public async Task RenderChangelogs_WithDuplicateFileNameInSameBundle_EmitsWarnin var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 9.2.0 @@ -110,14 +102,7 @@ public async Task RenderChangelogs_WithDuplicateFileNameInSameBundle_EmitsWarnin var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); - var input = new RenderChangelogsArguments - { - Bundles = - [ - new BundleInput { BundleFile = bundleFile } - ], - Output = outputDir - }; + var input = new RenderChangelogsArguments { Bundles = [new BundleInput { BundleFile = bundleFile }], Output = outputDir }; // Act var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -126,10 +111,13 @@ public async Task RenderChangelogs_WithDuplicateFileNameInSameBundle_EmitsWarnin result.Should().BeTrue(); Collector.Errors.Should().Be(0); Collector.Warnings.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Warning && - d.Message.Contains("appears multiple times in the same bundle") && - d.File == bundleFile); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Severity == Severity.Warning && d.Message.Contains("appears multiple times in the same bundle") && + d.File == bundleFile + ); } [Fact] @@ -165,8 +153,7 @@ public async Task RenderChangelogs_WithDuplicatePr_EmitsWarning() FileSystem.Directory.CreateDirectory(bundleDir); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 9.2.0 @@ -184,11 +171,7 @@ public async Task RenderChangelogs_WithDuplicatePr_EmitsWarning() var input = new RenderChangelogsArguments { - Bundles = - [ - new BundleInput { BundleFile = bundle1 }, - new BundleInput { BundleFile = bundle2 } - ], + Bundles = [new BundleInput { BundleFile = bundle1 }, new BundleInput { BundleFile = bundle2 }], Output = outputDir }; @@ -199,8 +182,6 @@ public async Task RenderChangelogs_WithDuplicatePr_EmitsWarning() result.Should().BeTrue(); Collector.Errors.Should().Be(0); Collector.Warnings.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Warning && - d.Message.Contains("appears in multiple bundles")); + Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Warning && d.Message.Contains("appears in multiple bundles")); } } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/ErrorHandlingTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/ErrorHandlingTests.cs index 3fec4c11f2..89733ad477 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/ErrorHandlingTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/ErrorHandlingTests.cs @@ -66,10 +66,14 @@ public async Task RenderChangelogs_EntryWithOnlyFileBlock_EmitsNoInlineContentEr // Assert result.Should().BeFalse(); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && - d.Message.Contains("Entry '1755268130-feature.yaml' in bundle has no inline content: title and type are required") && - d.Message.Contains("Re-create the bundle with 'changelog bundle'")); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Severity == Severity.Error && + d.Message.Contains("Entry '1755268130-feature.yaml' in bundle has no inline content: title and type are required") && + d.Message.Contains("Re-create the bundle with 'changelog bundle'") + ); } [Fact] @@ -81,8 +85,7 @@ public async Task RenderChangelogs_WithInvalidBundleStructure_ReturnsError() var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); // language=yaml - var bundleContent = - """ + var bundleContent = """ invalid_field: value """; await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); @@ -99,7 +102,9 @@ public async Task RenderChangelogs_WithInvalidBundleStructure_ReturnsError() // Assert result.Should().BeFalse(); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => d.Message.Contains("No changelog entries to render") || d.Message.Contains("Failed to deserialize")); + Collector.Diagnostics + .Should() + .Contain(d => d.Message.Contains("No changelog entries to render") || d.Message.Contains("Failed to deserialize")); } [Fact] @@ -134,9 +139,13 @@ public async Task RenderChangelogs_EntryMissingProducts_EmitsError() // Assert result.Should().BeFalse(); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && - d.Message.Contains("Entry 'Feature without products' in bundle is missing required field: products")); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Severity == Severity.Error && + d.Message.Contains("Entry 'Feature without products' in bundle is missing required field: products") + ); } [Fact] @@ -227,9 +236,12 @@ public async Task RenderChangelogs_WithUnknownType_EmitsError() // Assert result.Should().BeFalse(); Collector.Errors.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && - d.Message.Contains("Unknown type feature") && - d.Message.Contains("has no inline content: title and type are required")); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Severity == Severity.Error && d.Message.Contains("Unknown type feature") && + d.Message.Contains("has no inline content: title and type are required") + ); } } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/GfmRenderTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/GfmRenderTests.cs index 54c2b37181..ec5414cdd6 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/GfmRenderTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/GfmRenderTests.cs @@ -33,8 +33,7 @@ public async Task RenderChangelogs_WithGfmFileType_CreatesSingleGfmFile() FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 9.2.0 @@ -140,18 +139,19 @@ public async Task RenderChangelogs_WithGfmFileType_IncludesAllSectionTypes() FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 9.2.0 """; - var bundleContent = CreateResolvedBundleContent(bundleHeader, + var bundleContent = CreateResolvedBundleContent( + bundleHeader, ("feature.yaml", feature), ("breaking.yaml", breakingChange), ("deprecation.yaml", deprecation), ("bugfix.yaml", bugFix), - ("known-issue.yaml", knownIssue)); + ("known-issue.yaml", knownIssue) + ); await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); @@ -232,15 +232,16 @@ public async Task RenderChangelogs_WithGfmFileType_HandlesHighlights() FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 9.2.0 """; - var bundleContent = CreateResolvedBundleContent(bundleHeader, + var bundleContent = CreateResolvedBundleContent( + bundleHeader, ("highlight.yaml", highlightedFeature), - ("normal.yaml", normalFeature)); + ("normal.yaml", normalFeature) + ); await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); @@ -300,8 +301,7 @@ It spans multiple lines. FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 9.2.0 diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/HideFeaturesTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/HideFeaturesTests.cs index 0c3f4ed725..12f3a479ec 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/HideFeaturesTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/HideFeaturesTests.cs @@ -58,7 +58,8 @@ public async Task RenderChangelogs_WithHideFeatures_CommentsOutMatchingEntries() target: 9.2.0 """, ("1755268130-hidden.yaml", changelog1), - ("1755268140-visible.yaml", changelog2)); + ("1755268140-visible.yaml", changelog2) + ); await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); @@ -78,11 +79,13 @@ public async Task RenderChangelogs_WithHideFeatures_CommentsOutMatchingEntries() result.Should().BeTrue(); Collector.Errors.Should().Be(0); Collector.Warnings.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Warning && - d.Message.Contains("Hidden feature") && - d.Message.Contains("feature:hidden-api") && - d.Message.Contains("will be commented out")); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Severity == Severity.Warning && d.Message.Contains("Hidden feature") && d.Message.Contains("feature:hidden-api") && + d.Message.Contains("will be commented out") + ); var indexFile = FileSystem.Path.Join(outputDir, "9.2.0", "index.md"); FileSystem.File.Exists(indexFile).Should().BeTrue(); @@ -126,7 +129,8 @@ public async Task RenderChangelogs_WithHideFeatures_BreakingChange_UsesBlockComm - product: elasticsearch target: 9.2.0 """, - ("1755268130-breaking.yaml", changelog)); + ("1755268130-breaking.yaml", changelog) + ); await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); @@ -190,7 +194,8 @@ public async Task RenderChangelogs_WithHideFeatures_Deprecation_UsesBlockComment - product: elasticsearch target: 9.2.0 """, - ("1755268130-deprecation.yaml", changelog)); + ("1755268130-deprecation.yaml", changelog) + ); await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); @@ -275,7 +280,8 @@ public async Task RenderChangelogs_WithHideFeatures_CommaSeparated_CommentsOutMa """, ("1755268130-first.yaml", changelog1), ("1755268140-second.yaml", changelog2), - ("1755268150-visible.yaml", changelog3)); + ("1755268150-visible.yaml", changelog3) + ); await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); @@ -331,13 +337,18 @@ public async Task RenderChangelogs_WithHideFeatures_FromFile_CommentsOutMatching - product: elasticsearch target: 9.2.0 """, - ("1755268130-hidden.yaml", changelog)); + ("1755268130-hidden.yaml", changelog) + ); await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); // Create feature IDs file var featureIdsFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "feature-ids.txt"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(featureIdsFile)!); - await FileSystem.File.WriteAllTextAsync(featureIdsFile, "feature:from-file\nfeature:another", TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync( + featureIdsFile, + "feature:from-file\nfeature:another", + TestContext.Current.CancellationToken + ); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); @@ -389,7 +400,8 @@ public async Task RenderChangelogs_WithHideFeatures_CaseInsensitive_MatchesFeatu - product: elasticsearch target: 9.2.0 """, - ("1755268130-hidden.yaml", changelog)); + ("1755268130-hidden.yaml", changelog) + ); await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); @@ -459,7 +471,8 @@ public async Task RenderChangelogs_WithBundleHideFeatures_CommentsOutMatchingEnt - feature:from-bundle """, ("1755268130-hidden.yaml", changelog1), - ("1755268140-visible.yaml", changelog2)); + ("1755268140-visible.yaml", changelog2) + ); await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); @@ -546,7 +559,8 @@ public async Task RenderChangelogs_MergesCLIAndBundleHideFeatures() """, ("1755268130-cli.yaml", changelog1), ("1755268140-bundle.yaml", changelog2), - ("1755268150-visible.yaml", changelog3)); + ("1755268150-visible.yaml", changelog3) + ); await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/HighlightsRenderTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/HighlightsRenderTests.cs index f7c2e3520a..69028cc650 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/HighlightsRenderTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/HighlightsRenderTests.cs @@ -42,7 +42,8 @@ public async Task RenderChangelogs_WithHighlightedEntries_CreatesHighlightsFile( - product: elasticsearch target: 9.3.0 """, - ("1755268130-highlight-feature.yaml", changelog1)); + ("1755268130-highlight-feature.yaml", changelog1) + ); await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); @@ -105,7 +106,8 @@ public async Task RenderChangelogs_WithoutHighlightedEntries_DoesNotCreateHighli - product: elasticsearch target: 9.3.0 """, - ("1755268130-regular-feature.yaml", changelog1)); + ("1755268130-regular-feature.yaml", changelog1) + ); await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); @@ -158,7 +160,8 @@ public async Task RenderChangelogs_WithHighlightedEntries_IncludesHighlightsInAs - product: elasticsearch target: 9.3.0 """, - ("1755268130-highlight-enhancement.yaml", changelog1)); + ("1755268130-highlight-enhancement.yaml", changelog1) + ); await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); @@ -235,7 +238,8 @@ public async Task RenderChangelogs_WithMultipleHighlightedEntries_GroupsByArea() target: 9.3.0 """, ("1755268130-search.yaml", changelog1), - ("1755268140-indexing.yaml", changelog2)); + ("1755268140-indexing.yaml", changelog2) + ); await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/OutputFormatTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/OutputFormatTests.cs index 5972848dac..7c1dccad01 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/OutputFormatTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/OutputFormatTests.cs @@ -50,8 +50,7 @@ public async Task RenderChangelogs_WithCustomConfigPath_UsesSpecifiedConfigFile( var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 9.2.0 @@ -107,8 +106,7 @@ public async Task RenderChangelogs_WithAsciidocFileType_CreatesSingleAsciidocFil FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 9.2.0 @@ -205,16 +203,17 @@ public async Task RenderChangelogs_WithAsciidocFileType_ValidatesAsciidocFormat( FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 9.2.0 """; - var bundleContent = CreateResolvedBundleContent(bundleHeader, + var bundleContent = CreateResolvedBundleContent( + bundleHeader, ("1755268130-feature.yaml", featureChangelog), ("1755268140-bugfix.yaml", bugFixChangelog), - ("1755268150-breaking.yaml", breakingChangeChangelog)); + ("1755268150-breaking.yaml", breakingChangeChangelog) + ); await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); @@ -261,8 +260,9 @@ public async Task RenderChangelogs_WithAsciidocFileType_ValidatesAsciidocFormat( // Verify asciidoc list format (entries should start with *) var lines = asciidocContent.Split('\n'); - var entryLines = lines - .Where(l => l.TrimStart().StartsWith("* ", StringComparison.Ordinal) && !l.TrimStart().StartsWith("* *", StringComparison.Ordinal)).ToList(); + var entryLines = lines.Where( + l => l.TrimStart().StartsWith("* ", StringComparison.Ordinal) && !l.TrimStart().StartsWith("* *", StringComparison.Ordinal) + ).ToList(); entryLines.Should().HaveCountGreaterThanOrEqualTo(3, "should have at least 3 changelog entries"); // Verify no invalid markdown syntax (like ##) is present diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/TitleTargetTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/TitleTargetTests.cs index 826bb6e104..023bbb2363 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/TitleTargetTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/TitleTargetTests.cs @@ -33,8 +33,7 @@ public async Task RenderChangelogs_WithoutTitleAndNoTargets_EmitsWarning() var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch """; @@ -57,10 +56,13 @@ public async Task RenderChangelogs_WithoutTitleAndNoTargets_EmitsWarning() result.Should().BeTrue(); Collector.Errors.Should().Be(0); Collector.Warnings.Should().BeGreaterThan(0); - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Warning && - d.Message.Contains("No --title option provided") && - d.Message.Contains("default to 'unknown'")); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Severity == Severity.Warning && d.Message.Contains("No --title option provided") && + d.Message.Contains("default to 'unknown'") + ); } [Fact] @@ -85,8 +87,7 @@ public async Task RenderChangelogs_WithTitleAndNoTargets_NoWarning() var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch """; @@ -99,7 +100,8 @@ public async Task RenderChangelogs_WithTitleAndNoTargets_NoWarning() { Bundles = [new BundleInput { BundleFile = bundleFile }], Output = outputDir, - Title = "9.2.0" // Title is provided + Title = + "9.2.0" // Title is provided }; // Act @@ -109,9 +111,7 @@ public async Task RenderChangelogs_WithTitleAndNoTargets_NoWarning() result.Should().BeTrue(); Collector.Errors.Should().Be(0); // Should not have warning about missing title - Collector.Diagnostics.Should().NotContain(d => - d.Severity == Severity.Warning && - d.Message.Contains("No --title option provided")); + Collector.Diagnostics.Should().NotContain(d => d.Severity == Severity.Warning && d.Message.Contains("No --title option provided")); } [Fact] @@ -137,8 +137,7 @@ public async Task RenderChangelogs_WithIsoDateTarget_FormatsDateInHeading() var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 2026-05-04 @@ -194,8 +193,7 @@ public async Task RenderChangelogs_WithYearMonthTarget_FormatsDateInHeading() var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 2026-05 @@ -251,8 +249,7 @@ public async Task RenderChangelogs_WithExplicitDateTitle_DoesNotFormatTitle() var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); // language=yaml - var bundleHeader = - """ + var bundleHeader = """ products: - product: elasticsearch target: 2026-05-04 @@ -266,7 +263,8 @@ public async Task RenderChangelogs_WithExplicitDateTitle_DoesNotFormatTitle() { Bundles = [new BundleInput { BundleFile = bundleFile }], Output = outputDir, - Title = "2026-05-04" // Explicit title provided - should stay literal + Title = + "2026-05-04" // Explicit title provided - should stay literal }; // Act diff --git a/tests/Elastic.Changelog.Tests/Creation/CIEnrichmentTests.cs b/tests/Elastic.Changelog.Tests/Creation/CIEnrichmentTests.cs index e207d26e98..ff69521e25 100644 --- a/tests/Elastic.Changelog.Tests/Creation/CIEnrichmentTests.cs +++ b/tests/Elastic.Changelog.Tests/Creation/CIEnrichmentTests.cs @@ -13,8 +13,7 @@ namespace Elastic.Changelog.Tests.Creation; public class CIEnrichmentTests(ITestOutputHelper output) : ChangelogTestBase(output) { - private static CreateChangelogArguments DefaultInput() => - new() { Products = [] }; + private static CreateChangelogArguments DefaultInput() => new() { Products = [] }; private static IEnvironmentVariables FakeCIEnv( string? prNumber = null, @@ -23,7 +22,8 @@ private static IEnvironmentVariables FakeCIEnv( string? type = null, string? owner = null, string? repo = null, - string? products = null) + string? products = null + ) { var env = A.Fake(); A.CallTo(() => env.IsRunningOnCI).Returns(true); @@ -212,10 +212,7 @@ public void EnrichFromCI_InCI_ExplicitProducts_CLIWins() { var env = FakeCIEnv(prNumber: "42", title: "Fix", type: "bug-fix", products: "cloud-hosted, cloud-serverless"); var service = CreateServiceWithEnv(env); - var input = DefaultInput() with - { - Products = [new ProductArgument { Product = "elasticsearch" }] - }; + var input = DefaultInput() with { Products = [new ProductArgument { Product = "elasticsearch" }] }; var result = service.EnrichFromCI(input); diff --git a/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs b/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs index 043d536f0f..f955501600 100644 --- a/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs @@ -20,7 +20,8 @@ public class ChangelogCreationServiceTests(ITestOutputHelper output) : Changelog { private readonly IGitHubPrService _mockGitHub = A.Fake(); - private const string ConfigWithProductLabels = """ + private const string ConfigWithProductLabels = + """ pivot: types: feature: ">feature" @@ -52,7 +53,8 @@ private static IEnvironmentVariables FakeCIEnv( string? type = null, string? owner = null, string? repo = null, - string? products = null) + string? products = null + ) { var env = A.Fake(); A.CallTo(() => env.IsRunningOnCI).Returns(true); @@ -95,8 +97,7 @@ public async Task CreateChangelog_CIWithProducts_SkipsPrFetchAndSucceeds() var result = await service.CreateChangelog(Collector, input, CancellationToken.None); - A.CallTo(() => _mockGitHub.FetchPrInfoAsync(A._, A._, A._, A._)) - .MustNotHaveHappened(); + A.CallTo(() => _mockGitHub.FetchPrInfoAsync(A._, A._, A._, A._)).MustNotHaveHappened(); result.Should().BeTrue(); Collector.Errors.Should().Be(0); @@ -112,12 +113,11 @@ public async Task CreateChangelog_CIWithoutProducts_FallsBackToPrFetchForProduct await WriteConfig(ConfigWithProductLabels); FileSystem.Directory.CreateDirectory(Path.Join(Paths.WorkingDirectoryRoot.FullName, "output")); - A.CallTo(() => _mockGitHub.FetchPrInfoAsync("153344", "elastic", "cloud", A._)) - .Returns(new GitHubPrInfo - { - Title = "Cache tfconsole lookups and batch terraform console calls", - Labels = [">enhancement", "@Product:ECH", "@Product:ESS", "@Public"] - }); + A.CallTo(() => _mockGitHub.FetchPrInfoAsync("153344", "elastic", "cloud", A._)).Returns(new GitHubPrInfo + { + Title = "Cache tfconsole lookups and batch terraform console calls", + Labels = [">enhancement", "@Product:ECH", "@Product:ESS", "@Public"] + }); var env = FakeCIEnv( prNumber: "153344", @@ -138,8 +138,7 @@ public async Task CreateChangelog_CIWithoutProducts_FallsBackToPrFetchForProduct var result = await service.CreateChangelog(Collector, input, CancellationToken.None); - A.CallTo(() => _mockGitHub.FetchPrInfoAsync("153344", "elastic", "cloud", A._)) - .MustHaveHappenedOnceExactly(); + A.CallTo(() => _mockGitHub.FetchPrInfoAsync("153344", "elastic", "cloud", A._)).MustHaveHappenedOnceExactly(); result.Should().BeTrue(); Collector.Errors.Should().Be(0); @@ -155,12 +154,11 @@ public async Task CreateChangelog_CIWithoutProducts_NoPrProductLabels_FailsWithP await WriteConfig(ConfigWithProductLabels); FileSystem.Directory.CreateDirectory(Path.Join(Paths.WorkingDirectoryRoot.FullName, "output")); - A.CallTo(() => _mockGitHub.FetchPrInfoAsync("153344", "elastic", "cloud", A._)) - .Returns(new GitHubPrInfo - { - Title = "Cache tfconsole lookups and batch terraform console calls", - Labels = [">enhancement", "@Public"] - }); + A.CallTo(() => _mockGitHub.FetchPrInfoAsync("153344", "elastic", "cloud", A._)).Returns(new GitHubPrInfo + { + Title = "Cache tfconsole lookups and batch terraform console calls", + Labels = [">enhancement", "@Public"] + }); var env = FakeCIEnv( prNumber: "153344", @@ -181,8 +179,7 @@ public async Task CreateChangelog_CIWithoutProducts_NoPrProductLabels_FailsWithP var result = await service.CreateChangelog(Collector, input, CancellationToken.None); - A.CallTo(() => _mockGitHub.FetchPrInfoAsync("153344", "elastic", "cloud", A._)) - .MustHaveHappenedOnceExactly(); + A.CallTo(() => _mockGitHub.FetchPrInfoAsync("153344", "elastic", "cloud", A._)).MustHaveHappenedOnceExactly(); result.Should().BeFalse(); Collector.Errors.Should().Be(1); @@ -211,13 +208,7 @@ public async Task CreateChangelog_OutputSubdirectory_Succeeds() ); var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, FileSystem, _mockGitHub, env); - var input = new CreateChangelogArguments - { - Products = [], - Config = configPath, - Output = output, - Concise = true - }; + var input = new CreateChangelogArguments { Products = [], Config = configPath, Output = output, Concise = true }; var result = await service.CreateChangelog(Collector, input, TestContext.Current.CancellationToken); diff --git a/tests/Elastic.Changelog.Tests/Creation/FilenameStrategyTests.cs b/tests/Elastic.Changelog.Tests/Creation/FilenameStrategyTests.cs index 6460a917ab..09c1760944 100644 --- a/tests/Elastic.Changelog.Tests/Creation/FilenameStrategyTests.cs +++ b/tests/Elastic.Changelog.Tests/Creation/FilenameStrategyTests.cs @@ -10,8 +10,7 @@ namespace Elastic.Changelog.Tests.Creation; public class FilenameStrategyTests { - private static CreateChangelogArguments DefaultInput() => - new() { Products = [] }; + private static CreateChangelogArguments DefaultInput() => new() { Products = [] }; [Fact] public void ApplyConfigDefaults_FilenamePr_SetsUsePrNumber() diff --git a/tests/Elastic.Changelog.Tests/Creation/NormalizeReferencesTests.cs b/tests/Elastic.Changelog.Tests/Creation/NormalizeReferencesTests.cs index b049f79a32..05e37efbdd 100644 --- a/tests/Elastic.Changelog.Tests/Creation/NormalizeReferencesTests.cs +++ b/tests/Elastic.Changelog.Tests/Creation/NormalizeReferencesTests.cs @@ -16,20 +16,15 @@ namespace Elastic.Changelog.Tests.Creation; public class NormalizeReferencesTests { [Fact] - public void Null_ReturnsNull() => - ChangelogFileWriter.NormalizeReferences(null, "elastic", "cloud", "pull") - .Should().BeNull(); + public void Null_ReturnsNull() => ChangelogFileWriter.NormalizeReferences(null, "elastic", "cloud", "pull").Should().BeNull(); [Fact] - public void Empty_ReturnsNull() => - ChangelogFileWriter.NormalizeReferences([], "elastic", "cloud", "pull") - .Should().BeNull(); + public void Empty_ReturnsNull() => ChangelogFileWriter.NormalizeReferences([], "elastic", "cloud", "pull").Should().BeNull(); [Fact] public void BareNumber_WithOwnerAndRepo_ExpandsToFullUrl() { - var result = ChangelogFileWriter.NormalizeReferences( - ["155500"], "elastic", "cloud", "pull"); + var result = ChangelogFileWriter.NormalizeReferences(["155500"], "elastic", "cloud", "pull"); result.Should().BeEquivalentTo(["https://github.com/elastic/cloud/pull/155500"]); } @@ -37,8 +32,7 @@ public void BareNumber_WithOwnerAndRepo_ExpandsToFullUrl() [Fact] public void BareIssueNumber_WithOwnerAndRepo_ExpandsToIssuesUrl() { - var result = ChangelogFileWriter.NormalizeReferences( - ["4274"], "elastic", "cloud", "issues"); + var result = ChangelogFileWriter.NormalizeReferences(["4274"], "elastic", "cloud", "issues"); result.Should().BeEquivalentTo(["https://github.com/elastic/cloud/issues/4274"]); } @@ -46,8 +40,7 @@ public void BareIssueNumber_WithOwnerAndRepo_ExpandsToIssuesUrl() [Fact] public void BareNumber_WithoutOwner_LeftAsIs() { - var result = ChangelogFileWriter.NormalizeReferences( - ["155500"], null, "cloud", "pull"); + var result = ChangelogFileWriter.NormalizeReferences(["155500"], null, "cloud", "pull"); result.Should().BeEquivalentTo(["155500"]); } @@ -55,8 +48,7 @@ public void BareNumber_WithoutOwner_LeftAsIs() [Fact] public void BareNumber_WithoutRepo_LeftAsIs() { - var result = ChangelogFileWriter.NormalizeReferences( - ["155500"], "elastic", null, "pull"); + var result = ChangelogFileWriter.NormalizeReferences(["155500"], "elastic", null, "pull"); result.Should().BeEquivalentTo(["155500"]); } @@ -66,8 +58,7 @@ public void BareNumber_WithBundleStyleRepo_LeftAsIs() { // `elasticsearch+kibana` is a multi-repo bundle string; we can't pick which one a bare // number targets, so we conservatively leave it alone. - var result = ChangelogFileWriter.NormalizeReferences( - ["100"], "elastic", "elasticsearch+kibana", "pull"); + var result = ChangelogFileWriter.NormalizeReferences(["100"], "elastic", "elasticsearch+kibana", "pull"); result.Should().BeEquivalentTo(["100"]); } @@ -76,8 +67,7 @@ public void BareNumber_WithBundleStyleRepo_LeftAsIs() public void BareNumber_WithSlashInRepo_LeftAsIs() { // A pre-qualified `org/repo` value supplied through `--repo` shouldn't be re-combined. - var result = ChangelogFileWriter.NormalizeReferences( - ["100"], "elastic", "elastic/cloud", "pull"); + var result = ChangelogFileWriter.NormalizeReferences(["100"], "elastic", "elastic/cloud", "pull"); result.Should().BeEquivalentTo(["100"]); } @@ -85,8 +75,7 @@ public void BareNumber_WithSlashInRepo_LeftAsIs() [Fact] public void FullUrl_LeftAsIs() { - var result = ChangelogFileWriter.NormalizeReferences( - ["https://github.com/elastic/cloud/pull/155500"], "elastic", "cloud", "pull"); + var result = ChangelogFileWriter.NormalizeReferences(["https://github.com/elastic/cloud/pull/155500"], "elastic", "cloud", "pull"); result.Should().BeEquivalentTo(["https://github.com/elastic/cloud/pull/155500"]); } @@ -94,8 +83,7 @@ public void FullUrl_LeftAsIs() [Fact] public void ShortFormReference_LeftAsIs() { - var result = ChangelogFileWriter.NormalizeReferences( - ["elastic/cloud#155500"], "elastic", "cloud", "pull"); + var result = ChangelogFileWriter.NormalizeReferences(["elastic/cloud#155500"], "elastic", "cloud", "pull"); result.Should().BeEquivalentTo(["elastic/cloud#155500"]); } @@ -104,15 +92,13 @@ public void ShortFormReference_LeftAsIs() public void MixedReferences_OnlyBareNumbersExpand() { var result = ChangelogFileWriter.NormalizeReferences( - [ - "155500", - "https://github.com/elastic/cloud/pull/155501", - "elastic/cloud#155502" - ], - "elastic", "cloud", "pull"); - - result.Should().BeEquivalentTo( - [ + ["155500", "https://github.com/elastic/cloud/pull/155501", "elastic/cloud#155502"], + "elastic", + "cloud", + "pull" + ); + + result.Should().BeEquivalentTo([ "https://github.com/elastic/cloud/pull/155500", "https://github.com/elastic/cloud/pull/155501", "elastic/cloud#155502" @@ -122,8 +108,7 @@ public void MixedReferences_OnlyBareNumbersExpand() [Fact] public void NumberWithWhitespace_TrimmedAndExpanded() { - var result = ChangelogFileWriter.NormalizeReferences( - [" 155500 "], "elastic", "cloud", "pull"); + var result = ChangelogFileWriter.NormalizeReferences([" 155500 "], "elastic", "cloud", "pull"); result.Should().BeEquivalentTo(["https://github.com/elastic/cloud/pull/155500"]); } diff --git a/tests/Elastic.Changelog.Tests/Creation/PrInfoProcessorLabelBlockerTests.cs b/tests/Elastic.Changelog.Tests/Creation/PrInfoProcessorLabelBlockerTests.cs index 63e953c73f..a2ca95d79f 100644 --- a/tests/Elastic.Changelog.Tests/Creation/PrInfoProcessorLabelBlockerTests.cs +++ b/tests/Elastic.Changelog.Tests/Creation/PrInfoProcessorLabelBlockerTests.cs @@ -26,12 +26,7 @@ public void AreAllProductsBlocked_NoLabelsConfigured_ReturnsFalse() [Fact] public void AreAllProductsBlocked_GlobalExclude_MatchingLabel_ReturnsTrue() { - var rules = new CreateRules - { - Labels = ["changelog:skip"], - Mode = FieldMode.Exclude, - Match = MatchMode.Any - }; + var rules = new CreateRules { Labels = ["changelog:skip"], Mode = FieldMode.Exclude, Match = MatchMode.Any }; PrInfoProcessor.AreAllProductsBlocked(["changelog:skip", "type:feature"], rules).Should().BeTrue(); } @@ -39,12 +34,7 @@ public void AreAllProductsBlocked_GlobalExclude_MatchingLabel_ReturnsTrue() [Fact] public void AreAllProductsBlocked_GlobalExclude_NoMatchingLabel_ReturnsFalse() { - var rules = new CreateRules - { - Labels = ["changelog:skip"], - Mode = FieldMode.Exclude, - Match = MatchMode.Any - }; + var rules = new CreateRules { Labels = ["changelog:skip"], Mode = FieldMode.Exclude, Match = MatchMode.Any }; PrInfoProcessor.AreAllProductsBlocked(["type:feature"], rules).Should().BeFalse(); } @@ -52,12 +42,7 @@ public void AreAllProductsBlocked_GlobalExclude_NoMatchingLabel_ReturnsFalse() [Fact] public void AreAllProductsBlocked_GlobalInclude_NoneMatch_ReturnsTrue() { - var rules = new CreateRules - { - Labels = ["changelog:include"], - Mode = FieldMode.Include, - Match = MatchMode.Any - }; + var rules = new CreateRules { Labels = ["changelog:include"], Mode = FieldMode.Include, Match = MatchMode.Any }; PrInfoProcessor.AreAllProductsBlocked(["type:feature"], rules).Should().BeTrue(); } @@ -65,12 +50,7 @@ public void AreAllProductsBlocked_GlobalInclude_NoneMatch_ReturnsTrue() [Fact] public void AreAllProductsBlocked_GlobalInclude_HasMatch_ReturnsFalse() { - var rules = new CreateRules - { - Labels = ["changelog:include"], - Mode = FieldMode.Include, - Match = MatchMode.Any - }; + var rules = new CreateRules { Labels = ["changelog:include"], Mode = FieldMode.Include, Match = MatchMode.Any }; PrInfoProcessor.AreAllProductsBlocked(["changelog:include", "type:feature"], rules).Should().BeFalse(); } @@ -85,18 +65,8 @@ public void AreAllProductsBlocked_ProductOverride_OneNotBlocked_ReturnsFalse() Match = MatchMode.Any, ByProduct = new Dictionary { - ["elasticsearch"] = new() - { - Labels = ["changelog:skip"], - Mode = FieldMode.Exclude, - Match = MatchMode.Any - }, - ["kibana"] = new() - { - Labels = ["kibana:skip"], - Mode = FieldMode.Exclude, - Match = MatchMode.Any - } + ["elasticsearch"] = new() { Labels = ["changelog:skip"], Mode = FieldMode.Exclude, Match = MatchMode.Any }, + ["kibana"] = new() { Labels = ["kibana:skip"], Mode = FieldMode.Exclude, Match = MatchMode.Any } } }; @@ -114,18 +84,8 @@ public void AreAllProductsBlocked_ProductOverride_AllBlocked_ReturnsTrue() Match = MatchMode.Any, ByProduct = new Dictionary { - ["elasticsearch"] = new() - { - Labels = ["changelog:skip"], - Mode = FieldMode.Exclude, - Match = MatchMode.Any - }, - ["kibana"] = new() - { - Labels = ["changelog:skip"], - Mode = FieldMode.Exclude, - Match = MatchMode.Any - } + ["elasticsearch"] = new() { Labels = ["changelog:skip"], Mode = FieldMode.Exclude, Match = MatchMode.Any }, + ["kibana"] = new() { Labels = ["changelog:skip"], Mode = FieldMode.Exclude, Match = MatchMode.Any } } }; @@ -135,12 +95,7 @@ public void AreAllProductsBlocked_ProductOverride_AllBlocked_ReturnsTrue() [Fact] public void AreAllProductsBlocked_ExcludeMatchAll_OnlyBlocksWhenAllLabelsMatch() { - var rules = new CreateRules - { - Labels = ["skip-a", "skip-b"], - Mode = FieldMode.Exclude, - Match = MatchMode.All - }; + var rules = new CreateRules { Labels = ["skip-a", "skip-b"], Mode = FieldMode.Exclude, Match = MatchMode.All }; // Only one of the two exclude labels present — MatchMode.All means ALL pr labels must be in the exclude list PrInfoProcessor.AreAllProductsBlocked(["skip-a", "other"], rules).Should().BeFalse(); @@ -157,12 +112,7 @@ public void AreAllProductsBlocked_ProductOverrideBlocks_GlobalDoesNot_ReturnsFal Match = MatchMode.Any, ByProduct = new Dictionary { - ["elasticsearch"] = new() - { - Labels = [">test"], - Mode = FieldMode.Exclude, - Match = MatchMode.Any - } + ["elasticsearch"] = new() { Labels = [">test"], Mode = FieldMode.Exclude, Match = MatchMode.Any } } }; @@ -181,12 +131,7 @@ public void AreAllProductsBlocked_GlobalBlocks_ProductOverrideDoesNot_ReturnsFal Match = MatchMode.Any, ByProduct = new Dictionary { - ["elasticsearch"] = new() - { - Labels = [">test"], - Mode = FieldMode.Exclude, - Match = MatchMode.Any - } + ["elasticsearch"] = new() { Labels = [">test"], Mode = FieldMode.Exclude, Match = MatchMode.Any } } }; @@ -204,18 +149,19 @@ public void AreAllProductsBlocked_GlobalBlocks_ProductOverrideDoesNot_ReturnsFal // cloud-serverless: // exclude: ">non-issue, ILM" - private static CreateRules DocumentedExampleRules() => new() - { - Labels = [">non-issue"], - Mode = FieldMode.Exclude, - Match = MatchMode.Any, - ByProduct = new Dictionary + private static CreateRules DocumentedExampleRules() => + new() { - ["elasticsearch"] = new() { Labels = [">test"], Mode = FieldMode.Exclude, Match = MatchMode.Any }, - ["kibana"] = new() { Labels = [">test"], Mode = FieldMode.Exclude, Match = MatchMode.Any }, - ["cloud-serverless"] = new() { Labels = [">non-issue", "ILM"], Mode = FieldMode.Exclude, Match = MatchMode.Any } - } - }; + Labels = [">non-issue"], + Mode = FieldMode.Exclude, + Match = MatchMode.Any, + ByProduct = new Dictionary + { + ["elasticsearch"] = new() { Labels = [">test"], Mode = FieldMode.Exclude, Match = MatchMode.Any }, + ["kibana"] = new() { Labels = [">test"], Mode = FieldMode.Exclude, Match = MatchMode.Any }, + ["cloud-serverless"] = new() { Labels = [">non-issue", "ILM"], Mode = FieldMode.Exclude, Match = MatchMode.Any } + } + }; [Fact] public void DocExample_NonIssueOnly_NotAllBlocked() @@ -313,12 +259,7 @@ public void AreAllProductsBlocked_IncludeWithProductOverride_OverrideSatisfied_N [Fact] public void AreAllProductsBlocked_IncludeMatchAll_PartialMatch_ReturnsTrue() { - var rules = new CreateRules - { - Labels = ["required-a", "required-b"], - Mode = FieldMode.Include, - Match = MatchMode.All - }; + var rules = new CreateRules { Labels = ["required-a", "required-b"], Mode = FieldMode.Include, Match = MatchMode.All }; // Include + all: blocked if NOT ALL PR labels are in the include list // PR has "required-a" and "other" — "other" is not in the include list → blocked @@ -328,12 +269,7 @@ public void AreAllProductsBlocked_IncludeMatchAll_PartialMatch_ReturnsTrue() [Fact] public void AreAllProductsBlocked_IncludeMatchAll_AllMatch_ReturnsFalse() { - var rules = new CreateRules - { - Labels = ["required-a", "required-b"], - Mode = FieldMode.Include, - Match = MatchMode.All - }; + var rules = new CreateRules { Labels = ["required-a", "required-b"], Mode = FieldMode.Include, Match = MatchMode.All }; // All PR labels are in the include list → not blocked PrInfoProcessor.AreAllProductsBlocked(["required-a", "required-b"], rules).Should().BeFalse(); @@ -368,12 +304,7 @@ public void AreAllProductsBlocked_MixedModesAcrossProducts() [Fact] public void AreAllProductsBlocked_CaseInsensitiveMatching() { - var rules = new CreateRules - { - Labels = ["Changelog:Skip"], - Mode = FieldMode.Exclude, - Match = MatchMode.Any - }; + var rules = new CreateRules { Labels = ["Changelog:Skip"], Mode = FieldMode.Exclude, Match = MatchMode.Any }; PrInfoProcessor.AreAllProductsBlocked(["changelog:skip"], rules).Should().BeTrue(); PrInfoProcessor.AreAllProductsBlocked(["CHANGELOG:SKIP"], rules).Should().BeTrue(); diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs index 6148df8634..032da7e341 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs @@ -23,16 +23,10 @@ public class ChangelogArtifactEvaluationServiceTests(ITestOutputHelper output) : private static readonly string Root = Paths.WorkingDirectoryRoot.FullName; private static readonly string MetadataFilePath = Path.Join(Root, "artifact/metadata.json"); - private ChangelogArtifactEvaluationService CreateService() => - new(LoggerFactory, _mockGitHub, _mockCore, RunnerTempFileSystem); + private ChangelogArtifactEvaluationService CreateService() => new(LoggerFactory, _mockGitHub, _mockCore, RunnerTempFileSystem); private static EvaluateArtifactArguments DefaultArgs() => - new() - { - MetadataPath = MetadataFilePath, - Owner = "elastic", - Repo = "test-repo" - }; + new() { MetadataPath = MetadataFilePath, Owner = "elastic", Repo = "test-repo" }; private async Task WriteMetadata(ChangelogArtifactMetadata metadata, string? path = null) { @@ -64,18 +58,16 @@ private static ChangelogArtifactMetadata DefaultMetadata( }; private void SetupPrInfo(string headSha = "abc123", bool isFork = false, string[]? labels = null) => - A.CallTo(() => _mockGitHub.FetchPrInfoAsync("42", "elastic", "test-repo", A._)) - .Returns(new GitHubPrInfo - { - Title = "Test PR", - HeadSha = headSha, - HeadRef = "feature/test", - IsFork = isFork, - Labels = labels ?? ["type:feature"] - }); - - private void VerifyOutputSet(string name, string value) => - A.CallTo(() => _mockCore.SetOutputAsync(name, value)).MustHaveHappened(); + A.CallTo(() => _mockGitHub.FetchPrInfoAsync("42", "elastic", "test-repo", A._)).Returns(new GitHubPrInfo + { + Title = "Test PR", + HeadSha = headSha, + HeadRef = "feature/test", + IsFork = isFork, + Labels = labels ?? ["type:feature"] + }); + + private void VerifyOutputSet(string name, string value) => A.CallTo(() => _mockCore.SetOutputAsync(name, value)).MustHaveHappened(); [Fact] public async Task EvaluateArtifact_MissingMetadata_ReturnsTrue() @@ -85,16 +77,14 @@ public async Task EvaluateArtifact_MissingMetadata_ReturnsTrue() var result = await service.EvaluateArtifact(Collector, DefaultArgs(), CancellationToken.None); result.Should().BeTrue(); - A.CallTo(() => _mockGitHub.FetchPrInfoAsync(A._, A._, A._, A._)) - .MustNotHaveHappened(); + A.CallTo(() => _mockGitHub.FetchPrInfoAsync(A._, A._, A._, A._)).MustNotHaveHappened(); } [Fact] public async Task EvaluateArtifact_FetchPrFails_ReturnsFalse() { await WriteMetadata(DefaultMetadata()); - A.CallTo(() => _mockGitHub.FetchPrInfoAsync("42", "elastic", "test-repo", A._)) - .Returns((GitHubPrInfo?)null); + A.CallTo(() => _mockGitHub.FetchPrInfoAsync("42", "elastic", "test-repo", A._)).Returns((GitHubPrInfo?)null); var service = CreateService(); var result = await service.EvaluateArtifact(Collector, DefaultArgs(), CancellationToken.None); @@ -118,10 +108,7 @@ public async Task EvaluateArtifact_HeadShaMoved_ReturnsTrueWithoutSettingFlags() [Fact] public async Task EvaluateArtifact_AllProductsBlocked_ReturnsTrueGracefully() { - var metadata = DefaultMetadata() with - { - CreateRules = new CreateRules { Labels = ["changelog:skip"], Mode = FieldMode.Exclude } - }; + var metadata = DefaultMetadata() with { CreateRules = new CreateRules { Labels = ["changelog:skip"], Mode = FieldMode.Exclude } }; await WriteMetadata(metadata); SetupPrInfo(labels: ["changelog:skip", "type:feature"]); @@ -169,12 +156,7 @@ public async Task EvaluateArtifact_SuccessCannotCommit_SetsCommentSuccessFlag() [Fact] public async Task EvaluateArtifact_ForkCanCommit_SetsCommitFlag() { - var metadata = DefaultMetadata(canCommit: true) with - { - IsFork = true, - HeadRepo = "contributor/repo", - MaintainerCanModify = true - }; + var metadata = DefaultMetadata(canCommit: true) with { IsFork = true, HeadRepo = "contributor/repo", MaintainerCanModify = true }; await WriteMetadata(metadata); SetupPrInfo(); @@ -190,12 +172,7 @@ public async Task EvaluateArtifact_ForkCanCommit_SetsCommitFlag() [Fact] public async Task EvaluateArtifact_ForkCannotCommit_SetsCommentSuccessFlag() { - var metadata = DefaultMetadata(canCommit: false) with - { - IsFork = true, - HeadRepo = "contributor/repo", - MaintainerCanModify = false - }; + var metadata = DefaultMetadata(canCommit: false) with { IsFork = true, HeadRepo = "contributor/repo", MaintainerCanModify = false }; await WriteMetadata(metadata); SetupPrInfo(); diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactMetadataTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactMetadataTests.cs index fe17f8817f..cbeffdec12 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactMetadataTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactMetadataTests.cs @@ -37,12 +37,7 @@ public void SerializationRoundTrip_WithAllFields_PreservesValues() Match = MatchMode.Any, ByProduct = new Dictionary { - ["elasticsearch"] = new() - { - Labels = ["es:skip"], - Mode = FieldMode.Exclude, - Match = MatchMode.All - } + ["elasticsearch"] = new() { Labels = ["es:skip"], Mode = FieldMode.Exclude, Match = MatchMode.All } } } }; @@ -144,12 +139,7 @@ public void Serialization_EnumsUseStringValues() IsFork = false, CanCommit = true, MaintainerCanModify = false, - CreateRules = new CreateRules - { - Labels = ["skip"], - Mode = FieldMode.Include, - Match = MatchMode.All - } + CreateRules = new CreateRules { Labels = ["skip"], Mode = FieldMode.Include, Match = MatchMode.All } }; var json = JsonSerializer.Serialize(metadata, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrBodyReaderTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrBodyReaderTests.cs index 32043275d0..c8d65187f8 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrBodyReaderTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrBodyReaderTests.cs @@ -39,9 +39,9 @@ public async Task ReadAsync_PrBodyFileMissing_EmitsWarning() var result = await ChangelogPrBodyReader.ReadAsync(bodyPath, collector, scopedFs, TestContext.Current.CancellationToken); result.Should().BeNull(); - collector.Diagnostics.Should().ContainSingle(d => - d.Severity == Severity.Warning && - d.Message.Contains("points to a missing file", StringComparison.Ordinal)); + collector.Diagnostics + .Should() + .ContainSingle(d => d.Severity == Severity.Warning && d.Message.Contains("points to a missing file", StringComparison.Ordinal)); } [Fact] @@ -58,9 +58,9 @@ public async Task ReadAsync_PrBodyFileOutsideScope_EmitsWarning() var result = await ChangelogPrBodyReader.ReadAsync(bodyPath, collector, scopedFs, TestContext.Current.CancellationToken); result.Should().BeNull(); - collector.Diagnostics.Should().ContainSingle(d => - d.Severity == Severity.Warning && - d.Message.Contains("PR_BODY_FILE", StringComparison.Ordinal)); + collector.Diagnostics + .Should() + .ContainSingle(d => d.Severity == Severity.Warning && d.Message.Contains("PR_BODY_FILE", StringComparison.Ordinal)); } [Fact] diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs index da29e64f33..a1c8f79aea 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs @@ -21,7 +21,8 @@ public class ChangelogPrEvaluationServiceTests : ChangelogTestBase private readonly ICoreService _mockCore; // Minimal valid YAML config that satisfies all required types - private const string MinimalConfig = """ + private const string MinimalConfig = + """ pivot: types: feature: "type:feature" @@ -36,7 +37,8 @@ public class ChangelogPrEvaluationServiceTests : ChangelogTestBase security: """; - private const string ConfigWithProducts = """ + private const string ConfigWithProducts = + """ pivot: types: feature: "type:feature" @@ -60,10 +62,12 @@ public ChangelogPrEvaluationServiceTests(ITestOutputHelper output) : base(output _mockCore = A.Fake(); // FakeItEasy returns "" for string by default; ensure GitHub methods return null - A.CallTo(() => _mockGitHub.FetchLastFileCommitAuthorAsync(A._, A._, A._, A._, A._)) - .Returns((string?)null); - A.CallTo(() => _mockGitHub.FetchCommitAuthorAsync(A._, A._, A._, A._)) - .Returns((string?)null); + A.CallTo( + () => _mockGitHub.FetchLastFileCommitAuthorAsync(A._, A._, A._, A._, A._) + ).Returns((string?)null); + A.CallTo(() => _mockGitHub.FetchCommitAuthorAsync(A._, A._, A._, A._)).Returns( + (string?)null + ); } private ChangelogPrEvaluationService CreateService() => @@ -105,8 +109,7 @@ private async Task WriteMinimalConfig(string? configPath = null, string? content await FileSystem.File.WriteAllTextAsync(configPath, content ?? MinimalConfig); } - private void VerifyOutputSet(string name, string value) => - A.CallTo(() => _mockCore.SetOutputAsync(name, value)).MustHaveHappened(); + private void VerifyOutputSet(string name, string value) => A.CallTo(() => _mockCore.SetOutputAsync(name, value)).MustHaveHappened(); [Fact] public async Task EvaluatePr_EditedNoRelevantChange_ReturnsSkipped() @@ -152,8 +155,9 @@ public async Task EvaluatePr_EditedWithBodyChange_DoesNotSkip() [Fact] public async Task EvaluatePr_BotCommit_ReturnsSkipped() { - A.CallTo(() => _mockGitHub.FetchCommitAuthorAsync("elastic", "test-repo", "abc123", A._)) - .Returns("github-actions[bot]"); + A.CallTo(() => _mockGitHub.FetchCommitAuthorAsync("elastic", "test-repo", "abc123", A._)).Returns( + "github-actions[bot]" + ); var service = CreateService(); var args = DefaultArgs(eventAction: "synchronize"); @@ -168,11 +172,22 @@ public async Task EvaluatePr_BotCommit_ReturnsSkipped() public async Task EvaluatePr_ManuallyEdited_PrFilename_ReturnsManuallyEdited() { FileSystem.Directory.CreateDirectory(Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog")); - await FileSystem.File.WriteAllTextAsync(Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog/42.yaml"), "title: test", TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync( + Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog/42.yaml"), + "title: test", + TestContext.Current.CancellationToken + ); - A.CallTo(() => _mockGitHub.FetchLastFileCommitAuthorAsync( - "elastic", "test-repo", "docs/changelog/42.yaml", "feature/test", A._)) - .Returns("human-user"); + A.CallTo( + () => + _mockGitHub.FetchLastFileCommitAuthorAsync( + "elastic", + "test-repo", + "docs/changelog/42.yaml", + "feature/test", + A._ + ) + ).Returns("human-user"); var service = CreateService(); var args = DefaultArgs(); @@ -187,12 +202,22 @@ public async Task EvaluatePr_ManuallyEdited_PrFilename_ReturnsManuallyEdited() public async Task EvaluatePr_ManuallyEdited_TimestampFilename_ReturnsManuallyEdited() { FileSystem.Directory.CreateDirectory(Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog")); - await FileSystem.File.WriteAllTextAsync(Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog/1735689600-fix-something.yaml"), - "title: Fix something\nprs:\n - \"42\"", TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync( + Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog/1735689600-fix-something.yaml"), + "title: Fix something\nprs:\n - \"42\"", + TestContext.Current.CancellationToken + ); - A.CallTo(() => _mockGitHub.FetchLastFileCommitAuthorAsync( - "elastic", "test-repo", "docs/changelog/1735689600-fix-something.yaml", "feature/test", A._)) - .Returns("human-user"); + A.CallTo( + () => + _mockGitHub.FetchLastFileCommitAuthorAsync( + "elastic", + "test-repo", + "docs/changelog/1735689600-fix-something.yaml", + "feature/test", + A._ + ) + ).Returns("human-user"); var service = CreateService(); var args = DefaultArgs(); @@ -214,9 +239,9 @@ public async Task EvaluatePr_NoExistingFile_SkipsManualEditCheck() result.Should().BeTrue(); VerifyOutputSet("status", "proceed"); - A.CallTo(() => _mockGitHub.FetchLastFileCommitAuthorAsync( - A._, A._, A._, A._, A._ - )).MustNotHaveHappened(); + A.CallTo( + () => _mockGitHub.FetchLastFileCommitAuthorAsync(A._, A._, A._, A._, A._) + ).MustNotHaveHappened(); } [Fact] @@ -252,7 +277,10 @@ public async Task EvaluatePr_NoTypeLabel_WithProductConfig_OutputsProductLabelTa { await WriteMinimalConfig(Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml"), ConfigWithProducts); var service = CreateService(); - var args = DefaultArgs(prLabels: ["unrelated-label"], config: Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml")); + var args = DefaultArgs( + prLabels: ["unrelated-label"], + config: Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml") + ); var result = await service.EvaluatePr(Collector, args, CancellationToken.None); @@ -267,7 +295,10 @@ public async Task EvaluatePr_NoTypeLabel_WithProductLabels_DoesNotOutputProductL { await WriteMinimalConfig(Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml"), ConfigWithProducts); var service = CreateService(); - var args = DefaultArgs(prLabels: ["@Product:ECH"], config: Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml")); + var args = DefaultArgs( + prLabels: ["@Product:ECH"], + config: Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml") + ); var result = await service.EvaluatePr(Collector, args, CancellationToken.None); @@ -297,10 +328,7 @@ public async Task EvaluatePr_StripTitlePrefix_RemovesBrackets() { await WriteMinimalConfig(); var service = CreateService(); - var args = DefaultArgs(prTitle: "[Inference API] Fix timeout handling") with - { - StripTitlePrefix = true - }; + var args = DefaultArgs(prTitle: "[Inference API] Fix timeout handling") with { StripTitlePrefix = true }; var result = await service.EvaluatePr(Collector, args, CancellationToken.None); @@ -313,10 +341,7 @@ public async Task EvaluatePr_StripTitlePrefix_RemovesKibanaStyleTeamHyphenSepara { await WriteMinimalConfig(); var service = CreateService(); - var args = DefaultArgs(prTitle: "[Cases] - Enable cases numerical id service") with - { - StripTitlePrefix = true - }; + var args = DefaultArgs(prTitle: "[Cases] - Enable cases numerical id service") with { StripTitlePrefix = true }; var result = await service.EvaluatePr(Collector, args, CancellationToken.None); @@ -339,11 +364,7 @@ public async Task EvaluatePr_NoConfig_UsesDefaults() [Fact] public void BuildLabelTable_WithEntries_BuildsMarkdownTable() { - var labelToType = new Dictionary - { - ["type:feature"] = "feature", - ["type:bug"] = "bug-fix" - }; + var labelToType = new Dictionary { ["type:feature"] = "feature", ["type:bug"] = "bug-fix" }; var table = ChangelogPrEvaluationService.BuildLabelTable(labelToType); @@ -362,11 +383,7 @@ public void BuildLabelTable_NullOrEmpty_ReturnsEmpty() [Fact] public void BuildProductLabelTable_WithEntries_BuildsMarkdownTable() { - var labelToProducts = new Dictionary - { - ["@Product:ECH"] = "cloud-hosted", - ["@Product:ESS"] = "cloud-serverless" - }; + var labelToProducts = new Dictionary { ["@Product:ECH"] = "cloud-hosted", ["@Product:ESS"] = "cloud-serverless" }; var table = ChangelogPrEvaluationService.BuildProductLabelTable(labelToProducts); @@ -398,8 +415,11 @@ public async Task EvaluatePr_ExistingTimestampFile_OutputsFilename() { await WriteMinimalConfig(); FileSystem.Directory.CreateDirectory(Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog")); - await FileSystem.File.WriteAllTextAsync(Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog/1735689600-fix-something.yaml"), - "title: Fix something\nprs:\n - \"42\"", TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync( + Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog/1735689600-fix-something.yaml"), + "title: Fix something\nprs:\n - \"42\"", + TestContext.Current.CancellationToken + ); var service = CreateService(); var args = DefaultArgs(); @@ -416,7 +436,11 @@ public async Task EvaluatePr_ExistingPrFile_OutputsFilename() { await WriteMinimalConfig(); FileSystem.Directory.CreateDirectory(Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog")); - await FileSystem.File.WriteAllTextAsync(Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog/42.yaml"), "title: Fix something", TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync( + Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog/42.yaml"), + "title: Fix something", + TestContext.Current.CancellationToken + ); var service = CreateService(); var args = DefaultArgs(); @@ -446,8 +470,7 @@ public void FindExistingChangelog_TimestampFilename_FindsByContent() { var dir = Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog"); FileSystem.Directory.CreateDirectory(dir); - FileSystem.File.WriteAllText(Path.Join(dir, "1735689600-fix.yaml"), - "title: Fix\nprs:\n - \"42\""); + FileSystem.File.WriteAllText(Path.Join(dir, "1735689600-fix.yaml"), "title: Fix\nprs:\n - \"42\""); var service = CreateService(); var result = service.FindExistingChangelog(dir, 42); @@ -460,8 +483,10 @@ public void FindExistingChangelog_GitHubUrl_FindsByContent() { var dir = Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/changelog"); FileSystem.Directory.CreateDirectory(dir); - FileSystem.File.WriteAllText(Path.Join(dir, "1735689600-fix.yaml"), - "title: Fix\nprs:\n - \"https://github.com/elastic/test-repo/pull/42\""); + FileSystem.File.WriteAllText( + Path.Join(dir, "1735689600-fix.yaml"), + "title: Fix\nprs:\n - \"https://github.com/elastic/test-repo/pull/42\"" + ); var service = CreateService(); var result = service.FindExistingChangelog(dir, 42); @@ -544,7 +569,8 @@ public async Task EvaluatePr_WithoutProductLabels_ReturnsNoLabelAndOutputsProduc [Fact] public async Task EvaluatePr_SingleProductConfigured_WithoutLabel_AutoAssignsProduct() { - var singleProductConfig = """ + var singleProductConfig = + """ pivot: types: feature: "type:feature" @@ -577,7 +603,8 @@ public async Task EvaluatePr_SingleProductConfigured_WithMultipleLabelAliases_Au { // Multi-label aliasing for the same product is still a single-product config — should // not require any explicit label on the PR. - var configWithAliases = """ + var configWithAliases = + """ pivot: types: feature: "type:feature" @@ -610,7 +637,8 @@ public async Task EvaluatePr_SingleProductConfigured_WithMultipleLabelAliases_Au [Fact] public async Task EvaluatePr_WithoutProductLabels_WithDefaultProducts_ReturnsProceed() { - var configWithDefaults = """ + var configWithDefaults = + """ pivot: types: feature: "type:feature" @@ -647,10 +675,7 @@ public async Task EvaluatePr_ShortReleaseNote_UsesPrTitleAndDescription() { await WriteMinimalConfig(); var service = CreateService(); - var args = DefaultArgs( - prTitle: "Some PR title", - prBody: "Release Notes: Added new search API endpoint" - ); + var args = DefaultArgs(prTitle: "Some PR title", prBody: "Release Notes: Added new search API endpoint"); var result = await service.EvaluatePr(Collector, args, CancellationToken.None); @@ -665,10 +690,7 @@ public async Task EvaluatePr_LongReleaseNote_UsedAsDescription_PrTitleAsTitle() await WriteMinimalConfig(); var service = CreateService(); var longNote = new string('x', 130); - var args = DefaultArgs( - prTitle: "Some PR title", - prBody: $"Release Notes: {longNote}" - ); + var args = DefaultArgs(prTitle: "Some PR title", prBody: $"Release Notes: {longNote}"); var result = await service.EvaluatePr(Collector, args, CancellationToken.None); @@ -682,10 +704,7 @@ public async Task EvaluatePr_NoReleaseNote_FallsBackToPrTitle() { await WriteMinimalConfig(); var service = CreateService(); - var args = DefaultArgs( - prTitle: "Fix something", - prBody: "This PR fixes a bug in the search API." - ); + var args = DefaultArgs(prTitle: "Fix something", prBody: "This PR fixes a bug in the search API."); var result = await service.EvaluatePr(Collector, args, CancellationToken.None); @@ -710,7 +729,8 @@ public async Task EvaluatePr_NullBody_FallsBackToPrTitle() [Fact] public async Task EvaluatePr_ExtractionDisabled_IgnoresReleaseNote() { - var configWithExtractionDisabled = """ + var configWithExtractionDisabled = + """ extract: release_notes: false pivot: @@ -728,10 +748,7 @@ public async Task EvaluatePr_ExtractionDisabled_IgnoresReleaseNote() """; await WriteMinimalConfig(content: configWithExtractionDisabled); var service = CreateService(); - var args = DefaultArgs( - prTitle: "Original PR title", - prBody: "Release Notes: Should be ignored" - ); + var args = DefaultArgs(prTitle: "Original PR title", prBody: "Release Notes: Should be ignored"); var result = await service.EvaluatePr(Collector, args, CancellationToken.None); @@ -775,9 +792,9 @@ public async Task EvaluatePr_NoTitle_EmitsErrorAndReturnsFalse() result.Should().BeFalse(); VerifyOutputSet("status", "no-title"); - Collector.Diagnostics.Should().ContainSingle(d => - d.Severity == Severity.Error && - d.Message.Contains("no title", StringComparison.OrdinalIgnoreCase)); + Collector.Diagnostics + .Should() + .ContainSingle(d => d.Severity == Severity.Error && d.Message.Contains("no title", StringComparison.OrdinalIgnoreCase)); } [Fact] @@ -791,9 +808,9 @@ public async Task EvaluatePr_NoTypeLabel_EmitsErrorAndReturnsFalse() result.Should().BeFalse(); VerifyOutputSet("status", "no-label"); - Collector.Diagnostics.Should().ContainSingle(d => - d.Severity == Severity.Error && - d.Message.Contains("label", StringComparison.OrdinalIgnoreCase)); + Collector.Diagnostics + .Should() + .ContainSingle(d => d.Severity == Severity.Error && d.Message.Contains("label", StringComparison.OrdinalIgnoreCase)); } [Fact] @@ -810,9 +827,9 @@ public async Task EvaluatePr_NoProductLabel_EmitsErrorAndReturnsFalse() result.Should().BeFalse(); VerifyOutputSet("status", "no-label"); - Collector.Diagnostics.Should().ContainSingle(d => - d.Severity == Severity.Error && - d.Message.Contains("label", StringComparison.OrdinalIgnoreCase)); + Collector.Diagnostics + .Should() + .ContainSingle(d => d.Severity == Severity.Error && d.Message.Contains("label", StringComparison.OrdinalIgnoreCase)); } [Fact] @@ -831,8 +848,7 @@ public async Task EvaluatePr_Success_DoesNotEmitErrors() // --- CollectExcludeLabels unit tests --- [Fact] - public void CollectExcludeLabels_Null_ReturnsNull() => - ChangelogPrEvaluationService.CollectExcludeLabels(null).Should().BeNull(); + public void CollectExcludeLabels_Null_ReturnsNull() => ChangelogPrEvaluationService.CollectExcludeLabels(null).Should().BeNull(); [Fact] public void CollectExcludeLabels_NoLabels_ReturnsNull() => @@ -841,11 +857,7 @@ public void CollectExcludeLabels_NoLabels_ReturnsNull() => [Fact] public void CollectExcludeLabels_GlobalExcludeLabels_ReturnsCommaSeparated() { - var rules = new CreateRules - { - Mode = FieldMode.Exclude, - Labels = [">non-issue", ">test"] - }; + var rules = new CreateRules { Mode = FieldMode.Exclude, Labels = [">non-issue", ">test"] }; var result = ChangelogPrEvaluationService.CollectExcludeLabels(rules); @@ -856,11 +868,7 @@ public void CollectExcludeLabels_GlobalExcludeLabels_ReturnsCommaSeparated() [Fact] public void CollectExcludeLabels_IncludeMode_ReturnsNull() { - var rules = new CreateRules - { - Mode = FieldMode.Include, - Labels = [">non-issue"] - }; + var rules = new CreateRules { Mode = FieldMode.Include, Labels = [">non-issue"] }; ChangelogPrEvaluationService.CollectExcludeLabels(rules).Should().BeNull(); } @@ -923,7 +931,8 @@ public void CollectExcludeLabels_PerProductIncludeMode_IgnoresIncludeProducts() // --- skip-labels output integration tests --- - private const string ConfigWithExcludeRules = """ + private const string ConfigWithExcludeRules = + """ pivot: types: feature: "type:feature" diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs index bff6350b26..fca4b6abca 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs @@ -24,7 +24,8 @@ public class ChangelogPrepareArtifactServiceTests(ITestOutputHelper output) : Ch private static readonly string OutputDir = Path.Join(Root, "output"); private static readonly string ConfigPath = Path.Join(Root, "config/changelog.yml"); - private const string MinimalConfig = """ + private const string MinimalConfig = + """ pivot: types: feature: "type:feature" @@ -42,8 +43,7 @@ public class ChangelogPrepareArtifactServiceTests(ITestOutputHelper output) : Ch exclude: "changelog:skip" """; - private ChangelogPrepareArtifactService CreateService() => - new(LoggerFactory, ConfigurationContext, _mockCore, RunnerTempFileSystem); + private ChangelogPrepareArtifactService CreateService() => new(LoggerFactory, ConfigurationContext, _mockCore, RunnerTempFileSystem); private PrepareArtifactArguments DefaultArgs( string evaluateStatus = "proceed", @@ -110,13 +110,7 @@ public async Task PrepareArtifact_ForkFields_PersistedInMetadata() await SetupStagingYaml(); await SetupConfig(); var service = CreateService(); - var args = DefaultArgs() with - { - IsFork = true, - HeadRepo = "contributor/repo", - CanCommit = true, - MaintainerCanModify = true - }; + var args = DefaultArgs() with { IsFork = true, HeadRepo = "contributor/repo", CanCommit = true, MaintainerCanModify = true }; await service.PrepareArtifact(Collector, args, CancellationToken.None); @@ -133,13 +127,7 @@ public async Task PrepareArtifact_ForkNoMaintainerEdits_CanCommitFalse() await SetupStagingYaml(); await SetupConfig(); var service = CreateService(); - var args = DefaultArgs() with - { - IsFork = true, - HeadRepo = "contributor/repo", - CanCommit = false, - MaintainerCanModify = false - }; + var args = DefaultArgs() with { IsFork = true, HeadRepo = "contributor/repo", CanCommit = false, MaintainerCanModify = false }; await service.PrepareArtifact(Collector, args, CancellationToken.None); @@ -161,13 +149,7 @@ public async Task PrepareArtifact_NullableBoolsUnspecified_CoerceToFalseInMetada await SetupStagingYaml(); await SetupConfig(); var service = CreateService(); - var args = DefaultArgs() with - { - IsFork = null, - CanCommit = null, - MaintainerCanModify = null, - HeadRepo = null - }; + var args = DefaultArgs() with { IsFork = null, CanCommit = null, MaintainerCanModify = null, HeadRepo = null }; await service.PrepareArtifact(Collector, args, CancellationToken.None); @@ -183,11 +165,7 @@ public async Task PrepareArtifact_ProductLabelTableAndSkipLabels_PersistedInMeta await SetupStagingYaml(); await SetupConfig(); var service = CreateService(); - var args = DefaultArgs() with - { - ProductLabelTable = "| Label | Product |\n| --- | --- |", - SkipLabels = "changelog:skip,skip-ci" - }; + var args = DefaultArgs() with { ProductLabelTable = "| Label | Product |\n| --- | --- |", SkipLabels = "changelog:skip,skip-ci" }; await service.PrepareArtifact(Collector, args, CancellationToken.None); @@ -300,7 +278,8 @@ public async Task PrepareArtifact_WithBomPrefixedYaml_NormalizesOutput() FileSystem.Directory.CreateDirectory(StagingDir); // Create YAML with BOM prefix - const string yamlContent = """ + const string yamlContent = + """ title: Test changelog type: feature products: @@ -318,11 +297,7 @@ public async Task PrepareArtifact_WithBomPrefixedYaml_NormalizesOutput() ChangelogUtf8Normalization.HasUtf8Bom(stagingBytes).Should().BeTrue("staging file should contain BOM"); var service = CreateService(); - var args = DefaultArgs() with - { - EvaluateStatus = "proceed", - GenerateOutcome = "success" - }; + var args = DefaultArgs() with { EvaluateStatus = "proceed", GenerateOutcome = "success" }; // Act await service.PrepareArtifact(Collector, args, CancellationToken.None); diff --git a/tests/Elastic.Changelog.Tests/Migration/ReleaseNotesFixture.cs b/tests/Elastic.Changelog.Tests/Migration/ReleaseNotesFixture.cs index d5a528d2a9..24ca58c844 100644 --- a/tests/Elastic.Changelog.Tests/Migration/ReleaseNotesFixture.cs +++ b/tests/Elastic.Changelog.Tests/Migration/ReleaseNotesFixture.cs @@ -25,7 +25,8 @@ public static class ReleaseNotesFixture }; // language=markdown - public const string Markdown = """ + public const string Markdown = + """ --- navigation_title: EDOT Java description: Release notes for Elastic Distribution of OpenTelemetry Java. diff --git a/tests/Elastic.Changelog.Tests/Migration/ReleaseNotesPageParserTests.cs b/tests/Elastic.Changelog.Tests/Migration/ReleaseNotesPageParserTests.cs index 150a135be4..6eb77d930e 100644 --- a/tests/Elastic.Changelog.Tests/Migration/ReleaseNotesPageParserTests.cs +++ b/tests/Elastic.Changelog.Tests/Migration/ReleaseNotesPageParserTests.cs @@ -58,12 +58,11 @@ public void Parse_TypedSubsections_MapToEntryTypes() release.Bundle.Entries[0].Type.Should().Be(ChangelogEntryType.BreakingChange); release.Bundle.Entries[1].Type.Should().Be(ChangelogEntryType.Deprecation); - ParseFixtureVersion("1.7.0").Bundle.Entries.Should() - .Contain(e => e.Type == ChangelogEntryType.Enhancement) - .And.Contain(e => e.Type == ChangelogEntryType.KnownIssue); + ParseFixtureVersion("1.7.0").Bundle.Entries.Should().Contain(e => e.Type == ChangelogEntryType.Enhancement).And.Contain( + e => e.Type == ChangelogEntryType.KnownIssue + ); - ParseFixtureVersion("1.4.1").Bundle.Entries.Should() - .ContainSingle().Which.Type.Should().Be(ChangelogEntryType.BugFix); + ParseFixtureVersion("1.4.1").Bundle.Entries.Should().ContainSingle().Which.Type.Should().Be(ChangelogEntryType.BugFix); } [Fact] @@ -101,8 +100,7 @@ public void Parse_EntryProducts_CarryTheScopeProduct() { var entries = ParseFixtureVersion("1.7.0").Bundle.Entries; - entries.Should().AllSatisfy(e => - e.Products.Should().ContainSingle().Which.ProductId.Should().Be("edot-java")); + entries.Should().AllSatisfy(e => e.Products.Should().ContainSingle().Which.ProductId.Should().Be("edot-java")); } [Fact] @@ -149,7 +147,8 @@ public void Parse_CommentTemplateLines_NeverProduceContent() [Fact] public void Parse_NonVersionHeading_SkippedWithWarning() { - var markdown = """ + var markdown = + """ ## Overview [some-anchor] Not release content. diff --git a/tests/Elastic.Changelog.Tests/Migration/WebMigrationServiceTests.cs b/tests/Elastic.Changelog.Tests/Migration/WebMigrationServiceTests.cs index ffce519f42..f8fbb3dde1 100644 --- a/tests/Elastic.Changelog.Tests/Migration/WebMigrationServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Migration/WebMigrationServiceTests.cs @@ -33,28 +33,18 @@ public class WebMigrationServiceTests public WebMigrationServiceTests(ITestOutputHelper output) { - _mockFileSystem = new MockFileSystem(new MockFileSystemOptions - { - CurrentDirectory = Paths.WorkingDirectoryRoot.FullName - }); + _mockFileSystem = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); _fileSystem = CheckoutsFileSystem.FromWorkingDirectory(_mockFileSystem).Write; _collector = new TestDiagnosticsCollector(output); - _httpHandler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(ReleaseNotesFixture.Markdown) - }); + _httpHandler = + new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(ReleaseNotesFixture.Markdown) }); } - private WebMigrationService CreateService() => - new(NullLoggerFactory.Instance, _fileSystem, _s3Client, _httpHandler); + private WebMigrationService CreateService() => new(NullLoggerFactory.Instance, _fileSystem, _s3Client, _httpHandler); // Default arguments cover the whole checked-in scope table (today: edot-java only). - private static MigrateFromWebArguments Args(bool dryRun = false, string bucket = Bucket, string[]? versions = null) => new() - { - S3BucketName = bucket, - DryRun = dryRun, - Versions = versions ?? [] - }; + private static MigrateFromWebArguments Args(bool dryRun = false, string bucket = Bucket, string[]? versions = null) => + new() { S3BucketName = bucket, DryRun = dryRun, Versions = versions ?? [] }; private static string Key(string version) => $"bundle/edot-java/{version}.yaml"; @@ -63,30 +53,36 @@ private Dictionary FakeEmptyBucket() { var putEtags = new Dictionary(StringComparer.Ordinal); - A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)) - .Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); - A.CallTo(() => _s3Client.GetObjectAsync(A._, A._)) - .Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); - A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) - .ReturnsLazily((PutObjectRequest request, CancellationToken _) => - { - using var buffer = new MemoryStream(); - request.InputStream.CopyTo(buffer); - var etag = Convert.ToHexStringLower(MD5.HashData(buffer.ToArray())); - putEtags[request.Key] = etag; - return new PutObjectResponse { ETag = $"\"{etag}\"" }; - }); + A.CallTo( + () => _s3Client.GetObjectMetadataAsync(A._, A._) + ).Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); + A.CallTo(() => _s3Client.GetObjectAsync(A._, A._)).Throws(new AmazonS3Exception("Not Found") + { + StatusCode = HttpStatusCode.NotFound + }); + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)).ReturnsLazily(( + PutObjectRequest request, + CancellationToken _ + ) => + { + using var buffer = new MemoryStream(); + request.InputStream.CopyTo(buffer); + var etag = Convert.ToHexStringLower(MD5.HashData(buffer.ToArray())); + putEtags[request.Key] = etag; + return new PutObjectResponse { ETag = $"\"{etag}\"" }; + }); return putEtags; } /// Fakes a bucket where the given keys already exist with the given ETags. private void FakeExistingKeys(IReadOnlyDictionary etags) => - A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)) - .ReturnsLazily((GetObjectMetadataRequest request, CancellationToken _) => + A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)).ReturnsLazily( + (GetObjectMetadataRequest request, CancellationToken _) => etags.TryGetValue(request.Key, out var etag) ? new GetObjectMetadataResponse { ETag = $"\"{etag}\"" } - : throw new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); + : throw new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound } + ); [Fact] public async Task FirstRun_CreatesEveryInScopeKeyWithCreateOnlySemantics() @@ -102,10 +98,13 @@ public async Task FirstRun_CreatesEveryInScopeKeyWithCreateOnlySemantics() foreach (var version in InScopeVersions) { - A.CallTo(() => _s3Client.PutObjectAsync( - A.That.Matches(r => r.Key == Key(version) && r.BucketName == Bucket && r.IfNoneMatch == "*"), - A._ - )).MustHaveHappenedOnceExactly(); + A.CallTo( + () => + _s3Client.PutObjectAsync( + A.That.Matches(r => r.Key == Key(version) && r.BucketName == Bucket && r.IfNoneMatch == "*"), + A._ + ) + ).MustHaveHappenedOnceExactly(); } service.LastResults.Where(r => r.Outcome == "created").Should().HaveCount(InScopeVersions.Length); @@ -124,10 +123,13 @@ public async Task Run_NeverWritesARegistryManifest() var result = await CreateService().MigrateFromWeb(_collector, Args(), ct); result.Should().BeTrue(); - A.CallTo(() => _s3Client.PutObjectAsync( - A.That.Matches(r => r.Key.EndsWith("registry.json", StringComparison.Ordinal)), - A._ - )).MustNotHaveHappened(); + A.CallTo( + () => + _s3Client.PutObjectAsync( + A.That.Matches(r => r.Key.EndsWith("registry.json", StringComparison.Ordinal)), + A._ + ) + ).MustNotHaveHappened(); } [Fact] @@ -147,8 +149,7 @@ public async Task SecondRun_OverSameScope_IsANoOpWithAllSkips() result.Should().BeTrue(); _collector.Errors.Should().Be(0); - A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) - .MustNotHaveHappened(); + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)).MustNotHaveHappened(); service.LastResults.Where(r => r.Detail.Contains("identical content")).Should().HaveCount(InScopeVersions.Length); } @@ -163,8 +164,7 @@ public async Task ExistingKeyWithDifferentContent_IsSkippedAndNeverOverwritten() var result = await service.MigrateFromWeb(_collector, Args(), ct); result.Should().BeTrue("skipping existing keys is the expected safe outcome, not a failure"); - A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) - .MustNotHaveHappened(); + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)).MustNotHaveHappened(); service.LastResults.Where(r => r.Detail.Contains("different content")).Should().HaveCount(InScopeVersions.Length); } @@ -173,8 +173,10 @@ public async Task ConcurrentCreate_PreconditionFailed_IsReportedAsSkipNotFailure { _ = FakeEmptyBucket(); // The key appears between the HEAD check and the conditional PUT: S3 answers 412. - A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) - .Throws(new AmazonS3Exception("Precondition Failed") { StatusCode = HttpStatusCode.PreconditionFailed }); + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)).Throws(new AmazonS3Exception( + "Precondition Failed" + ) + { StatusCode = HttpStatusCode.PreconditionFailed }); var service = CreateService(); var ct = TestContext.Current.CancellationToken; @@ -188,8 +190,10 @@ public async Task ConcurrentCreate_PreconditionFailed_IsReportedAsSkipNotFailure public async Task PutFailure_IsReportedPerKeyAndFailsTheRun() { _ = FakeEmptyBucket(); - A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) - .Throws(new AmazonS3Exception("Access Denied") { StatusCode = HttpStatusCode.Forbidden }); + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)).Throws(new AmazonS3Exception( + "Access Denied" + ) + { StatusCode = HttpStatusCode.Forbidden }); var service = CreateService(); var ct = TestContext.Current.CancellationToken; @@ -197,8 +201,10 @@ public async Task PutFailure_IsReportedPerKeyAndFailsTheRun() result.Should().BeFalse(); _collector.Errors.Should().BeGreaterThan(0); - service.LastResults.Where(r => r.Outcome == "failed" && r.Detail.Contains("Access Denied")) - .Should().HaveCount(InScopeVersions.Length); + service.LastResults + .Where(r => r.Outcome == "failed" && r.Detail.Contains("Access Denied")) + .Should() + .HaveCount(InScopeVersions.Length); } [Fact] @@ -212,9 +218,9 @@ public async Task VersionsBeyondTheCutoff_AreSkippedAndNeverUploaded() result.Should().BeTrue(); // 2.0.0 > cutoff 1.10.0: owned by the live pipeline. - A.CallTo(() => _s3Client.PutObjectAsync( - A.That.Matches(r => r.Key == Key("2.0.0")), A._ - )).MustNotHaveHappened(); + A.CallTo( + () => _s3Client.PutObjectAsync(A.That.Matches(r => r.Key == Key("2.0.0")), A._) + ).MustNotHaveHappened(); var cutoffResult = service.LastResults.Should().ContainSingle(r => r.Key == Key("2.0.0")).Subject; cutoffResult.Outcome.Should().Be("skipped"); cutoffResult.Detail.Should().Contain("beyond cutoff 1.10.0"); @@ -230,9 +236,9 @@ public async Task VersionsFilter_RestrictsTheRunToTheSelection() var result = await service.MigrateFromWeb(_collector, Args(versions: ["1.9.0"]), ct); result.Should().BeTrue(); - A.CallTo(() => _s3Client.PutObjectAsync( - A.That.Matches(r => r.Key == Key("1.9.0")), A._ - )).MustHaveHappenedOnceExactly(); + A.CallTo( + () => _s3Client.PutObjectAsync(A.That.Matches(r => r.Key == Key("1.9.0")), A._) + ).MustHaveHappenedOnceExactly(); service.LastResults.Where(r => r.Outcome == "created").Should().ContainSingle(); service.LastResults.Where(r => r.Detail.Contains("--versions")).Should().HaveCount(InScopeVersions.Length - 1); } @@ -263,8 +269,7 @@ public async Task DryRunWithBucket_InspectsExistenceButNeverWrites() var result = await service.MigrateFromWeb(_collector, Args(dryRun: true), ct); result.Should().BeTrue(); - A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) - .MustNotHaveHappened(); + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)).MustNotHaveHappened(); service.LastResults.Where(r => r.Outcome == "would-create").Should().HaveCount(InScopeVersions.Length - 1); service.LastResults.Should().ContainSingle(r => r.Key == Key("1.9.0") && r.Outcome == "skipped"); } @@ -320,8 +325,9 @@ public async Task FetchesTheMarkdownFromThePinnedRefOnRawGithubusercontent() _ = await service.MigrateFromWeb(_collector, Args(dryRun: true, bucket: ""), ct); - _httpHandler.RequestedPaths.Should().Equal( - "/elastic/elastic-otel-java/9a61ce4faaf08e272c433a083bcc6f0e96d80e0a/docs/release-notes/index.md"); + _httpHandler.RequestedPaths + .Should() + .Equal("/elastic/elastic-otel-java/9a61ce4faaf08e272c433a083bcc6f0e96d80e0a/docs/release-notes/index.md"); } private sealed class StubHandler(Func responder) : HttpMessageHandler diff --git a/tests/Elastic.Changelog.Tests/ProductArgumentTests.cs b/tests/Elastic.Changelog.Tests/ProductArgumentTests.cs index 6eefd11c31..ee8acd73f5 100644 --- a/tests/Elastic.Changelog.Tests/ProductArgumentTests.cs +++ b/tests/Elastic.Changelog.Tests/ProductArgumentTests.cs @@ -23,11 +23,7 @@ public void ToSpecString_FormatsCorrectly(string product, string? target, string [Fact] public void FormatProductSpecs_MultipleProducts_JoinsWithComma() { - var products = new List - { - new() { Product = "cloud-hosted" }, - new() { Product = "cloud-serverless" } - }; + var products = new List { new() { Product = "cloud-hosted" }, new() { Product = "cloud-serverless" } }; ProductArgument.FormatProductSpecs(products).Should().Be("cloud-hosted, cloud-serverless"); } @@ -48,8 +44,7 @@ public void FormatProductSpecs_WithTargetAndLifecycle_FormatsCorrectly() [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void ParseProductSpecs_NullOrEmpty_ReturnsEmpty(string? input) => - ProductArgument.ParseProductSpecs(input).Should().BeEmpty(); + public void ParseProductSpecs_NullOrEmpty_ReturnsEmpty(string? input) => ProductArgument.ParseProductSpecs(input).Should().BeEmpty(); [Fact] public void ParseProductSpecs_SingleProduct_ParsesCorrectly() diff --git a/tests/Elastic.Changelog.Tests/Reconciliation/BundleRegistryReconcilerTests.cs b/tests/Elastic.Changelog.Tests/Reconciliation/BundleRegistryReconcilerTests.cs index 70c9851f00..97efe68146 100644 --- a/tests/Elastic.Changelog.Tests/Reconciliation/BundleRegistryReconcilerTests.cs +++ b/tests/Elastic.Changelog.Tests/Reconciliation/BundleRegistryReconcilerTests.cs @@ -21,13 +21,15 @@ public class BundleRegistryReconcilerTests private readonly ReconcileMetrics _metrics = new(); public BundleRegistryReconcilerTests() => - _reconciler = new BundleRegistryReconciler( - NullLoggerFactory.Instance, - _s3.Client, - PublicBucket, - new FakeTimeProvider(FixedNow), - retryBaseDelay: TimeSpan.Zero, - _metrics); + _reconciler = + new BundleRegistryReconciler( + NullLoggerFactory.Instance, + _s3.Client, + PublicBucket, + new FakeTimeProvider(FixedNow), + retryBaseDelay: TimeSpan.Zero, + _metrics + ); private static ChangelogScope BundleScope(string product = "elasticsearch") { @@ -42,7 +44,8 @@ private static ChangelogScope ChangelogScopeFor(string org, string repo, string } // language=yaml - private static string BundleYaml(string product, string target) => $""" + private static string BundleYaml(string product, string target) => + $""" products: - product: {product} target: {target} @@ -57,7 +60,8 @@ private static string BundleYaml(string product, string target) => $""" """; // language=yaml - private const string AmendYaml = """ + private const string AmendYaml = + """ exclude-entries: - file: name: 1-feature.yaml @@ -101,23 +105,24 @@ public async Task ReconcileGroup_HealsEntriesMissingFromManifest() var etag2 = SeedBundle(scope, "es-9.2.0.yaml", "9.2.0"); _ = SeedBundle(scope, "es-9.3.0.yaml", "9.3.0"); _ = SeedBundle(scope, "es-9.4.0.yaml", "9.4.0"); - SeedManifest(scope, + SeedManifest( + scope, new RegistryBundle { File = "es-9.2.0.yaml", Target = "9.2.0", ETag = etag2 }, - new RegistryBundle { File = "es-9.1.0.yaml", Target = "9.1.0", ETag = etag1 }); + new RegistryBundle { File = "es-9.1.0.yaml", Target = "9.1.0", ETag = etag1 } + ); var outcome = await _reconciler.ReconcileGroupAsync(scope, Ctx); outcome.Should().Be(GroupReconcileOutcome.Written); var manifest = WrittenManifest(); - manifest.Bundles.Select(b => b.File).Should().Equal( - "es-9.4.0.yaml", "es-9.3.0.yaml", "es-9.2.0.yaml", "es-9.1.0.yaml"); + manifest.Bundles.Select(b => b.File).Should().Equal("es-9.4.0.yaml", "es-9.3.0.yaml", "es-9.2.0.yaml", "es-9.1.0.yaml"); manifest.Bundles.Should().OnlyContain(b => b.Target != null); manifest.Producer.Should().Be(BundleRegistryReconciler.Producer); // 1 and 2 were ETag-reused: only the manifest itself plus 3 and 4 were read. - _s3.GetsFor(PublicBucket).Should().BeEquivalentTo([ - scope.RegistryKey, scope.Prefix + "es-9.3.0.yaml", scope.Prefix + "es-9.4.0.yaml" - ]); + _s3.GetsFor(PublicBucket) + .Should() + .BeEquivalentTo([scope.RegistryKey, scope.Prefix + "es-9.3.0.yaml", scope.Prefix + "es-9.4.0.yaml"]); } [Fact] @@ -125,9 +130,11 @@ public async Task ReconcileGroup_DropsEntriesWhoseObjectIsGone() { var scope = BundleScope(); var etag1 = SeedBundle(scope, "es-9.1.0.yaml", "9.1.0"); - SeedManifest(scope, + SeedManifest( + scope, new RegistryBundle { File = "es-9.2.0.yaml", Target = "9.2.0", ETag = "gone" }, - new RegistryBundle { File = "es-9.1.0.yaml", Target = "9.1.0", ETag = etag1 }); + new RegistryBundle { File = "es-9.1.0.yaml", Target = "9.1.0", ETag = etag1 } + ); var outcome = await _reconciler.ReconcileGroupAsync(scope, Ctx); @@ -141,9 +148,11 @@ public async Task ReconcileGroup_ManifestAlreadyExact_SkipsWriteAndBundleReads() var scope = BundleScope(); var etag1 = SeedBundle(scope, "es-9.1.0.yaml", "9.1.0"); var etag2 = SeedBundle(scope, "es-9.2.0.yaml", "9.2.0"); - SeedManifest(scope, + SeedManifest( + scope, new RegistryBundle { File = "es-9.2.0.yaml", Target = "9.2.0", ETag = etag2 }, - new RegistryBundle { File = "es-9.1.0.yaml", Target = "9.1.0", ETag = etag1 }); + new RegistryBundle { File = "es-9.1.0.yaml", Target = "9.1.0", ETag = etag1 } + ); var outcome = await _reconciler.ReconcileGroupAsync(scope, Ctx); @@ -175,9 +184,11 @@ public async Task ReconcileGroup_AmendIsAlwaysRecomputed_EvenWhenItsETagMatches( var scope = BundleScope(); var parentETag = SeedBundle(scope, "es-9.3.0.yaml", "9.4.0"); var amendETag = _s3.Seed(PublicBucket, scope.Prefix + "es-9.3.0.amend-1.yaml", AmendYaml); - SeedManifest(scope, + SeedManifest( + scope, new RegistryBundle { File = "es-9.3.0.yaml", Target = "9.4.0", ETag = parentETag }, - new RegistryBundle { File = "es-9.3.0.amend-1.yaml", Target = "9.3.0", ETag = amendETag }); + new RegistryBundle { File = "es-9.3.0.amend-1.yaml", Target = "9.3.0", ETag = amendETag } + ); var outcome = await _reconciler.ReconcileGroupAsync(scope, Ctx); @@ -204,7 +215,8 @@ public async Task ReconcileGroup_AmendWithoutParent_RecordsNullTarget() public async Task ReconcileGroup_MultiProductBundle_MatchesTheGroupProduct() { // language=yaml - const string multiProductYaml = """ + const string multiProductYaml = + """ products: - product: elasticsearch target: 9.3.0 @@ -240,8 +252,12 @@ public async Task ReconcileGroup_ProducerMismatch_RecomputesEverythingAndWritesE // every future reconcile keeps recomputing. var scope = BundleScope(); var etag = SeedBundle(scope, "es-9.1.0.yaml", "9.1.0"); - SeedManifest(scope, producer: null, Registry.CurrentSchemaVersion, - new RegistryBundle { File = "es-9.1.0.yaml", Target = "9.1.0", ETag = etag }); + SeedManifest( + scope, + producer: null, + Registry.CurrentSchemaVersion, + new RegistryBundle { File = "es-9.1.0.yaml", Target = "9.1.0", ETag = etag } + ); var outcome = await _reconciler.ReconcileGroupAsync(scope, Ctx); @@ -379,8 +395,12 @@ public async Task ReconcileGroup_PutLosingTheRace_RereadsAndRetries() _s3.BeforePut = call => { if (call == 1) - SeedManifest(scope, producer: null, Registry.CurrentSchemaVersion, - new RegistryBundle { File = "concurrent.yaml", Target = null, ETag = "cc" }); + SeedManifest( + scope, + producer: null, + Registry.CurrentSchemaVersion, + new RegistryBundle { File = "concurrent.yaml", Target = null, ETag = "cc" } + ); }; var outcome = await _reconciler.ReconcileGroupAsync(scope, Ctx); @@ -398,8 +418,14 @@ public async Task ReconcileGroup_ExhaustedConditionalRetries_Throws() SeedManifest(scope, producer: null, Registry.CurrentSchemaVersion); // Every attempt loses: a concurrent writer lands between every read and write. var counter = 0; - _s3.BeforePut = _ => SeedManifest(scope, producer: null, Registry.CurrentSchemaVersion, - new RegistryBundle { File = $"concurrent-{counter++}.yaml", Target = null, ETag = "cc" }); + _s3.BeforePut = + _ => + SeedManifest( + scope, + producer: null, + Registry.CurrentSchemaVersion, + new RegistryBundle { File = $"concurrent-{counter++}.yaml", Target = null, ETag = "cc" } + ); var act = async () => await _reconciler.ReconcileGroupAsync(scope, Ctx); diff --git a/tests/Elastic.Changelog.Tests/Reconciliation/FakeS3.cs b/tests/Elastic.Changelog.Tests/Reconciliation/FakeS3.cs index 5681b2a982..8330c2aa56 100644 --- a/tests/Elastic.Changelog.Tests/Reconciliation/FakeS3.cs +++ b/tests/Elastic.Changelog.Tests/Reconciliation/FakeS3.cs @@ -23,8 +23,7 @@ namespace Elastic.Changelog.Tests.Reconciliation; ///
internal sealed class FakeS3 { - private readonly Dictionary> _buckets = - [with(StringComparer.Ordinal)]; + private readonly Dictionary> _buckets = [with(StringComparer.Ordinal)]; public IAmazonS3 Client { get; } = A.Fake(); @@ -64,26 +63,35 @@ public FakeS3(params string[] bucketNames) foreach (var bucket in bucketNames) _buckets[bucket] = [with(StringComparer.Ordinal)]; - _ = A.CallTo(() => Client.ListObjectsV2Async(A._, A._)) - .ReturnsLazily((ListObjectsV2Request r, CancellationToken _) => List(r)); - - _ = A.CallTo(() => Client.GetObjectAsync(A._, A._)) - .ReturnsLazily((GetObjectRequest r, CancellationToken _) => Get(r)); - - _ = A.CallTo(() => Client.GetObjectMetadataAsync(A._, A._)) - .ReturnsLazily((GetObjectMetadataRequest r, CancellationToken _) => Head(r)); - - _ = A.CallTo(() => Client.PutObjectAsync(A._, A._)) - .ReturnsLazily((PutObjectRequest r, CancellationToken _) => Put(r)); - - _ = A.CallTo(() => Client.DeleteObjectAsync(A._, A._)) - .ReturnsLazily((DeleteObjectRequest r, CancellationToken _) => Delete(r)); + _ = + A.CallTo(() => Client.ListObjectsV2Async(A._, A._)).ReturnsLazily( + (ListObjectsV2Request r, CancellationToken _) => List(r) + ); + + _ = + A.CallTo(() => Client.GetObjectAsync(A._, A._)).ReturnsLazily( + (GetObjectRequest r, CancellationToken _) => Get(r) + ); + + _ = + A.CallTo(() => Client.GetObjectMetadataAsync(A._, A._)).ReturnsLazily( + (GetObjectMetadataRequest r, CancellationToken _) => Head(r) + ); + + _ = + A.CallTo(() => Client.PutObjectAsync(A._, A._)).ReturnsLazily( + (PutObjectRequest r, CancellationToken _) => Put(r) + ); + + _ = + A.CallTo(() => Client.DeleteObjectAsync(A._, A._)).ReturnsLazily( + (DeleteObjectRequest r, CancellationToken _) => Delete(r) + ); } // MD5 is what real S3 uses for single-part ETags. [SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")] - public static string ETagOf(string content) => - Convert.ToHexStringLower(MD5.HashData(Encoding.UTF8.GetBytes(content))); + public static string ETagOf(string content) => Convert.ToHexStringLower(MD5.HashData(Encoding.UTF8.GetBytes(content))); /// Seeds or replaces an object; returns its (unquoted) ETag. public string Seed(string bucket, string key, string content) @@ -118,7 +126,10 @@ private ListObjectsV2Response List(ListObjectsV2Request request) var objects = new List(); var commonPrefixes = new SortedSet(StringComparer.Ordinal); - foreach (var (key, value) in store.Where(kv => kv.Key.StartsWith(prefix, StringComparison.Ordinal)).OrderBy(kv => kv.Key, StringComparer.Ordinal)) + foreach (var (key, value) in store.Where(kv => kv.Key.StartsWith(prefix, StringComparison.Ordinal)).OrderBy( + kv => kv.Key, + StringComparer.Ordinal + )) { var rest = key[prefix.Length..]; var slash = rest.IndexOf('/', StringComparison.Ordinal); @@ -146,9 +157,7 @@ private ListObjectsV2Response List(ListObjectsV2Request request) S3Objects = page, CommonPrefixes = [.. commonPrefixes], IsTruncated = truncated, - NextContinuationToken = truncated - ? (start + page.Count).ToString(System.Globalization.CultureInfo.InvariantCulture) - : null + NextContinuationToken = truncated ? (start + page.Count).ToString(System.Globalization.CultureInfo.InvariantCulture) : null }; } @@ -176,10 +185,7 @@ private GetObjectMetadataResponse Head(GetObjectMetadataRequest request) if (!Store(request.BucketName).TryGetValue(request.Key, out var obj)) throw NotFound(); - var response = new GetObjectMetadataResponse - { - ETag = $"\"{obj.ETag}\"" - }; + var response = new GetObjectMetadataResponse { ETag = $"\"{obj.ETag}\"" }; response.Headers.ContentType = "application/yaml"; return response; } @@ -223,9 +229,7 @@ private DeleteObjectResponse Delete(DeleteObjectRequest request) return new DeleteObjectResponse(); } - private static AmazonS3Exception NotFound() => - new("Not Found") { StatusCode = HttpStatusCode.NotFound }; + private static AmazonS3Exception NotFound() => new("Not Found") { StatusCode = HttpStatusCode.NotFound }; - private static AmazonS3Exception PreconditionFailed() => - new("Precondition Failed") { StatusCode = HttpStatusCode.PreconditionFailed }; + private static AmazonS3Exception PreconditionFailed() => new("Precondition Failed") { StatusCode = HttpStatusCode.PreconditionFailed }; } diff --git a/tests/Elastic.Changelog.Tests/Reconciliation/ShallowRegistryReconcilerTests.cs b/tests/Elastic.Changelog.Tests/Reconciliation/ShallowRegistryReconcilerTests.cs index b9e071ec37..a71bc031f0 100644 --- a/tests/Elastic.Changelog.Tests/Reconciliation/ShallowRegistryReconcilerTests.cs +++ b/tests/Elastic.Changelog.Tests/Reconciliation/ShallowRegistryReconcilerTests.cs @@ -20,8 +20,14 @@ public class ShallowRegistryReconcilerTests private readonly ShallowRegistryReconciler _reconciler; public ShallowRegistryReconcilerTests() => - _reconciler = new ShallowRegistryReconciler( - NullLoggerFactory.Instance, _s3.Client, PublicBucket, retryBaseDelay: TimeSpan.Zero, metrics: _metrics); + _reconciler = + new ShallowRegistryReconciler( + NullLoggerFactory.Instance, + _s3.Client, + PublicBucket, + retryBaseDelay: TimeSpan.Zero, + metrics: _metrics + ); private Cancel Ctx => TestContext.Current.CancellationToken; @@ -159,8 +165,14 @@ public async Task Reconcile_UnparseableMap_IsRebuiltFromTheTreeListing() await _reconciler.ReconcileAsync(ChangelogScopeKind.Bundle, [BundleScope("elasticsearch")], Ctx); Map(BundleMapKey).Keys.Should().BeEquivalentTo("elasticsearch", "kibana"); - _s3.Puts.Should().ContainSingle().Which.IfMatch.Trim('"').Should().Be(corruptETag, - "the conditional write must replace exactly the corrupt map that was read"); + _s3.Puts + .Should() + .ContainSingle() + .Which + .IfMatch + .Trim('"') + .Should() + .Be(corruptETag, "the conditional write must replace exactly the corrupt map that was read"); } [Fact] @@ -208,8 +220,7 @@ public async Task Reconcile_NoTouchedFolders_IsANoOp() [Fact] public async Task Reconcile_MismatchedScopeKind_IsRejected() { - var act = async () => await _reconciler.ReconcileAsync( - ChangelogScopeKind.Bundle, [PoolScope("elastic", "repo", "main")], Ctx); + var act = async () => await _reconciler.ReconcileAsync(ChangelogScopeKind.Bundle, [PoolScope("elastic", "repo", "main")], Ctx); _ = await act.Should().ThrowAsync(); } diff --git a/tests/Elastic.Changelog.Tests/ReleaseNoteParserTests.cs b/tests/Elastic.Changelog.Tests/ReleaseNoteParserTests.cs index 1a51819e52..7c796ddf23 100644 --- a/tests/Elastic.Changelog.Tests/ReleaseNoteParserTests.cs +++ b/tests/Elastic.Changelog.Tests/ReleaseNoteParserTests.cs @@ -102,9 +102,6 @@ public void Parse_GitHubDefaultFormat_AcceptsBothBullets() [Theory] [InlineData("## 🐛 Bug Fixes")] [InlineData("### 🐛 Bug Fixes")] - public void DetectFormat_EmojiHeadersAtAnyLevel_IsReleaseDrafter(string header) - { - ReleaseNoteParser.DetectFormat($"{header}\n\n- Fix it by @alice in #1") - .Should().Be(ReleaseNoteFormat.ReleaseDrafter); - } + public void DetectFormat_EmojiHeadersAtAnyLevel_IsReleaseDrafter(string header) => + ReleaseNoteParser.DetectFormat($"{header}\n\n- Fix it by @alice in #1").Should().Be(ReleaseNoteFormat.ReleaseDrafter); } diff --git a/tests/Elastic.Changelog.Tests/ReleaseNotesExtractorTests.cs b/tests/Elastic.Changelog.Tests/ReleaseNotesExtractorTests.cs index d5b0cd1027..9774513fa7 100644 --- a/tests/Elastic.Changelog.Tests/ReleaseNotesExtractorTests.cs +++ b/tests/Elastic.Changelog.Tests/ReleaseNotesExtractorTests.cs @@ -34,8 +34,7 @@ public void FindReleaseNote_WithReleaseNotesDash_ExtractsContent() { // Arrange // language=markdown - var prBody = - """ + var prBody = """ ## Summary Release Notes - Adds support for new aggregation types @@ -53,8 +52,7 @@ public void FindReleaseNote_WithReleaseNoteSingular_ExtractsContent() { // Arrange // language=markdown - var prBody = - """ + var prBody = """ ## Summary Release Note: Adds support for new aggregation types @@ -140,8 +138,7 @@ public void FindReleaseNote_WithNoReleaseNote_ReturnsNull() { // Arrange // language=markdown - var prBody = - """ + var prBody = """ ## Summary This PR adds a new feature but has no release notes section. @@ -186,14 +183,15 @@ public void FindReleaseNote_WithMultiLineReleaseNote_ExtractsUntilDoubleNewline( { // Arrange // language=markdown - var prBody = - """ + var prBody = """ Release Notes: This is a multi-line release note that spans multiple lines ## Next Section - """.ReplaceLineEndings("\n"); + """.ReplaceLineEndings( + "\n" + ); // Act var result = ReleaseNotesExtractor.FindReleaseNote(prBody); diff --git a/tests/Elastic.Changelog.Tests/Scrubbing/ScrubberProcessorTests.cs b/tests/Elastic.Changelog.Tests/Scrubbing/ScrubberProcessorTests.cs index 7ae9d72f49..b0d0f44dcd 100644 --- a/tests/Elastic.Changelog.Tests/Scrubbing/ScrubberProcessorTests.cs +++ b/tests/Elastic.Changelog.Tests/Scrubbing/ScrubberProcessorTests.cs @@ -27,15 +27,27 @@ public ScrubberProcessorTests() { // The real scrub pass has its own tests; here it just marks content so assertions can // tell a scrubbed write from a raw copy. - _ = A.CallTo(() => _scrubber.ScrubAsync(A._, A._, A._)) - .ReturnsLazily((string _, string content, Cancel _) => Task.FromResult("scrubbed: " + content)); + _ = + A.CallTo(() => _scrubber.ScrubAsync(A._, A._, A._)).ReturnsLazily( + (string _, string content, Cancel _) => Task.FromResult("scrubbed: " + content) + ); var reconciler = new BundleRegistryReconciler( - NullLoggerFactory.Instance, _s3.Client, PublicBucket, retryBaseDelay: TimeSpan.Zero, metrics: _metrics); + NullLoggerFactory.Instance, + _s3.Client, + PublicBucket, + retryBaseDelay: TimeSpan.Zero, + metrics: _metrics + ); var shallowReconciler = new ShallowRegistryReconciler( - NullLoggerFactory.Instance, _s3.Client, PublicBucket, retryBaseDelay: TimeSpan.Zero, metrics: _metrics); - _processor = new ScrubberProcessor( - NullLoggerFactory.Instance, _s3.Client, PublicBucket, _scrubber, reconciler, shallowReconciler, _metrics); + NullLoggerFactory.Instance, + _s3.Client, + PublicBucket, + retryBaseDelay: TimeSpan.Zero, + metrics: _metrics + ); + _processor = + new ScrubberProcessor(NullLoggerFactory.Instance, _s3.Client, PublicBucket, _scrubber, reconciler, shallowReconciler, _metrics); } private Cancel Ctx => TestContext.Current.CancellationToken; @@ -46,9 +58,13 @@ private static ScrubberQueueMessage Message(string eventName, string key, string { var id = $"msg-{Interlocked.Increment(ref MessageCounter)}"; // The shape S3 bucket notifications deliver to SQS (fields the processor reads). - var body = - "{\"Records\":[{\"eventName\":\"" + eventName + "\",\"s3\":{\"bucket\":{\"name\":\"" + bucket + - "\"},\"object\":{\"key\":\"" + key + "\"}}}]}"; + var body = "{\"Records\":[{\"eventName\":\"" + + eventName + + "\",\"s3\":{\"bucket\":{\"name\":\"" + + bucket + + "\"},\"object\":{\"key\":\"" + + key + + "\"}}}]}"; return new ScrubberQueueMessage(id, body); } @@ -91,14 +107,21 @@ public async Task Process_StaleCreatedEventAfterDelete_RemovesThePublicCopyAndMa // Private object is gone; a late ObjectCreated must converge on deletion — and the group // reconcile then removes the now-empty group's manifest. _ = _s3.Seed(PublicBucket, "bundle/elasticsearch/es-9.1.0.yaml", "stale-public"); - _ = _s3.Seed(PublicBucket, "bundle/elasticsearch/registry.json", - /*lang=json,strict*/ """{"schema_version":1,"product":"elasticsearch","generated_at":"2026-01-01T00:00:00+00:00","bundles":[]}"""); + _ = + _s3.Seed( + PublicBucket, + "bundle/elasticsearch/registry.json", + /*lang=json,strict*/ + """{"schema_version":1,"product":"elasticsearch","generated_at":"2026-01-01T00:00:00+00:00","bundles":[]}""" + ); var failed = await _processor.ProcessAsync([Message("ObjectCreated:Put", "bundle/elasticsearch/es-9.1.0.yaml")], Ctx); failed.Should().BeEmpty(); _s3.Exists(PublicBucket, "bundle/elasticsearch/es-9.1.0.yaml").Should().BeFalse(); - _s3.Exists(PublicBucket, "bundle/elasticsearch/registry.json").Should().BeFalse("an empty group's manifest is deleted: absent ≠ empty"); + _s3.Exists(PublicBucket, "bundle/elasticsearch/registry.json") + .Should() + .BeFalse("an empty group's manifest is deleted: absent ≠ empty"); } [Fact] @@ -108,7 +131,7 @@ public async Task Process_BundleRegistryKeyEvents_NeverCopyOrDelete_OnlyTriggerA // manifests (and Phase 3's cleanup will delete them) — those events may never touch the // public registry object directly, only schedule a reconcile that derives the public // manifest from public state. - _ = _s3.Seed(PrivateBucket, "bundle/elasticsearch/registry.json", /*lang=json,strict*/ """{"private":"manifest"}"""); + _ = _s3.Seed(PrivateBucket, "bundle/elasticsearch/registry.json", /*lang=json,strict*/ """{"private":"manifest"}"""); _ = _s3.Seed(PublicBucket, "bundle/elasticsearch/es-9.1.0.yaml", BundleYaml()); var failed = await _processor.ProcessAsync([Message("ObjectCreated:Put", "bundle/elasticsearch/registry.json")], Ctx); @@ -128,7 +151,7 @@ public async Task Process_PoolRegistryKeyEvents_ArePassedThroughVerbatim() // pool through its manifest, so the private copy is mirrored verbatim — never scrubbed, // never reconciled. const string poolRegistry = "changelog/elastic/kibana/main/registry.json"; - const string content = /*lang=json,strict*/ """{"schema_version":1,"bundles":[{"file":"100.yaml"}]}"""; + const string content = /*lang=json,strict*/ """{"schema_version":1,"bundles":[{"file":"100.yaml"}]}"""; _ = _s3.Seed(PrivateBucket, poolRegistry, content); var failed = await _processor.ProcessAsync([Message("ObjectCreated:Put", poolRegistry)], Ctx); @@ -163,7 +186,8 @@ public async Task Process_PoolYamlEvents_ScrubAndUpdateTheShallowMap_ButWriteNoP failed.Should().BeEmpty(); _s3.ContentOf(PublicBucket, "changelog/elastic/kibana/main/100.yaml").Should().Be("scrubbed: entry"); _s3.Exists(PublicBucket, "changelog/elastic/kibana/main/registry.json") - .Should().BeFalse("the reconciler no longer produces pool manifests"); + .Should() + .BeFalse("the reconciler no longer produces pool manifests"); _metrics.GroupReconciles.Should().Be(0); var map = ShallowMap("changelog/registry.json"); @@ -188,11 +212,14 @@ public async Task Process_MultiplePoolsInOneBatch_CoalesceIntoASingleShallowMapW _ = _s3.Seed(PrivateBucket, "changelog/elastic/kibana/main/100.yaml", "one"); _ = _s3.Seed(PrivateBucket, "changelog/elastic/elasticsearch/main/200.yaml", "two"); - var failed = await _processor.ProcessAsync( - [ - Message("ObjectCreated:Put", "changelog/elastic/kibana/main/100.yaml"), - Message("ObjectCreated:Put", "changelog/elastic/elasticsearch/main/200.yaml") - ], Ctx); + var failed = + await _processor.ProcessAsync( + [ + Message("ObjectCreated:Put", "changelog/elastic/kibana/main/100.yaml"), + Message("ObjectCreated:Put", "changelog/elastic/elasticsearch/main/200.yaml") + ], + Ctx + ); failed.Should().BeEmpty(); _s3.Puts.Where(p => p.Key == "changelog/registry.json").Should().ContainSingle("one tree gets one map write per batch"); @@ -205,11 +232,14 @@ public async Task Process_OtherJsonAndNonYamlKeys_AreSkipped() _ = _s3.Seed(PrivateBucket, "bundle/elasticsearch/stray.json", "{}"); _ = _s3.Seed(PrivateBucket, "bundle/elasticsearch/notes.txt", "text"); - var failed = await _processor.ProcessAsync( - [ - Message("ObjectCreated:Put", "bundle/elasticsearch/stray.json"), - Message("ObjectCreated:Put", "bundle/elasticsearch/notes.txt") - ], Ctx); + var failed = + await _processor.ProcessAsync( + [ + Message("ObjectCreated:Put", "bundle/elasticsearch/stray.json"), + Message("ObjectCreated:Put", "bundle/elasticsearch/notes.txt") + ], + Ctx + ); failed.Should().BeEmpty(); _s3.Puts.Should().BeEmpty(); @@ -221,12 +251,15 @@ public async Task Process_MultipleEventsForOneKey_CoalesceIntoASingleObjectRecon { _ = _s3.Seed(PrivateBucket, "bundle/elasticsearch/es-9.1.0.yaml", "content"); - var failed = await _processor.ProcessAsync( - [ - Message("ObjectCreated:Put", "bundle/elasticsearch/es-9.1.0.yaml"), - Message("ObjectRemoved:Delete", "bundle/elasticsearch/es-9.1.0.yaml"), - Message("ObjectCreated:Put", "bundle/elasticsearch/es-9.1.0.yaml") - ], Ctx); + var failed = + await _processor.ProcessAsync( + [ + Message("ObjectCreated:Put", "bundle/elasticsearch/es-9.1.0.yaml"), + Message("ObjectRemoved:Delete", "bundle/elasticsearch/es-9.1.0.yaml"), + Message("ObjectCreated:Put", "bundle/elasticsearch/es-9.1.0.yaml") + ], + Ctx + ); failed.Should().BeEmpty(); _metrics.ObjectReconciles.Should().Be(1, "the event type is ignored, so one key needs one look"); @@ -240,12 +273,15 @@ public async Task Process_MultipleKeysInOneGroup_CoalesceIntoASingleGroupReconci _ = _s3.Seed(PrivateBucket, "bundle/elasticsearch/es-9.2.0.yaml", "two"); _ = _s3.Seed(PrivateBucket, "bundle/elasticsearch/es-9.3.0.yaml", "three"); - var failed = await _processor.ProcessAsync( - [ - Message("ObjectCreated:Put", "bundle/elasticsearch/es-9.1.0.yaml"), - Message("ObjectCreated:Put", "bundle/elasticsearch/es-9.2.0.yaml"), - Message("ObjectCreated:Put", "bundle/elasticsearch/es-9.3.0.yaml") - ], Ctx); + var failed = + await _processor.ProcessAsync( + [ + Message("ObjectCreated:Put", "bundle/elasticsearch/es-9.1.0.yaml"), + Message("ObjectCreated:Put", "bundle/elasticsearch/es-9.2.0.yaml"), + Message("ObjectCreated:Put", "bundle/elasticsearch/es-9.3.0.yaml") + ], + Ctx + ); failed.Should().BeEmpty(); _metrics.ObjectReconciles.Should().Be(3); @@ -277,8 +313,10 @@ public async Task Process_FailedObjectReconcile_FailsOnlyItsOwnMessages() { _ = _s3.Seed(PrivateBucket, "bundle/elasticsearch/bad.yaml", "bad"); _ = _s3.Seed(PrivateBucket, "bundle/kibana/good.yaml", "good"); - _ = A.CallTo(() => _scrubber.ScrubAsync("bundle/elasticsearch/bad.yaml", A._, A._)) - .Throws(new InvalidOperationException("cannot scrub")); + _ = + A.CallTo(() => _scrubber.ScrubAsync("bundle/elasticsearch/bad.yaml", A._, A._)).Throws( + new InvalidOperationException("cannot scrub") + ); var badMessage = Message("ObjectCreated:Put", "bundle/elasticsearch/bad.yaml"); var goodMessage = Message("ObjectCreated:Put", "bundle/kibana/good.yaml"); @@ -308,8 +346,10 @@ public async Task Process_FailedGroupReconcile_FailsEveryContributingMessage() var failed = await _processor.ProcessAsync([first, second, other], Ctx); - failed.Should().BeEquivalentTo([first.MessageId, second.MessageId], - "every record that contributed to the failed group must redeliver"); + failed.Should().BeEquivalentTo( + [first.MessageId, second.MessageId], + "every record that contributed to the failed group must redeliver" + ); } [Fact] @@ -343,18 +383,22 @@ public async Task Process_BatchMixingObjectAndRegistryEvents_MarksGroupContribut _ = _s3.Seed(PrivateBucket, "bundle/elasticsearch/es-9.1.0.yaml", "one"); _ = _s3.Seed(PrivateBucket, "bundle/elasticsearch/registry.json", "{}"); - var failed = await _processor.ProcessAsync( - [ - Message("ObjectCreated:Put", "bundle/elasticsearch/es-9.1.0.yaml"), - Message("ObjectCreated:Put", "bundle/elasticsearch/registry.json") - ], Ctx); + var failed = + await _processor.ProcessAsync( + [ + Message("ObjectCreated:Put", "bundle/elasticsearch/es-9.1.0.yaml"), + Message("ObjectCreated:Put", "bundle/elasticsearch/registry.json") + ], + Ctx + ); failed.Should().BeEmpty(); _metrics.GroupReconciles.Should().Be(1); } // language=yaml - private static string BundleYaml() => """ + private static string BundleYaml() => + """ products: - product: elasticsearch target: 9.1.0 diff --git a/tests/Elastic.Changelog.Tests/TestHelpers.cs b/tests/Elastic.Changelog.Tests/TestHelpers.cs index f8aafe19ed..24d62935e9 100644 --- a/tests/Elastic.Changelog.Tests/TestHelpers.cs +++ b/tests/Elastic.Changelog.Tests/TestHelpers.cs @@ -18,8 +18,7 @@ public void Write(Diagnostic diagnostic) } } -public class TestDiagnosticsCollector(ITestOutputHelper output) - : DiagnosticsCollector([new TestDiagnosticsOutput(output)]) +public class TestDiagnosticsCollector(ITestOutputHelper output) : DiagnosticsCollector([new TestDiagnosticsOutput(output)]) { private readonly List _diagnostics = []; @@ -50,8 +49,13 @@ public void Dispose() { } public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Trace; - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => - output?.WriteLine(formatter(state, exception)); + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) => output?.WriteLine(formatter(state, exception)); } public class TestLoggerProvider(ITestOutputHelper? output) : ILoggerProvider @@ -69,4 +73,3 @@ public void AddProvider(ILoggerProvider provider) { } public ILogger CreateLogger(string categoryName) => new TestLogger(output); } - diff --git a/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs b/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs index 975b0ba88f..cc0076d6f4 100644 --- a/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs @@ -29,10 +29,7 @@ public class ChangelogUploadServiceTests public ChangelogUploadServiceTests(ITestOutputHelper output) { - _mockFileSystem = new MockFileSystem(new MockFileSystemOptions - { - CurrentDirectory = Paths.WorkingDirectoryRoot.FullName - }); + _mockFileSystem = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); _fileSystem = ChangelogFileSystem.FromWorkingDirectory(_mockFileSystem); _service = new ChangelogUploadService(NullLoggerFactory.Instance, fileSystem: _fileSystem, s3Client: _s3Client); _collector = new TestDiagnosticsCollector(output); @@ -51,7 +48,9 @@ private string AddChangelog(string fileName, string yaml) public void DiscoverUploadTargets_SingleEntry_MapsToPoolScopedKey() { // language=yaml - var path = AddChangelog("entry.yaml", """ + var path = AddChangelog( + "entry.yaml", + """ title: New feature type: feature products: @@ -59,7 +58,8 @@ public void DiscoverUploadTargets_SingleEntry_MapsToPoolScopedKey() target: 9.2.0 prs: - "100" - """); + """ + ); var targets = _service.DiscoverUploadTargets(_collector, _changelogDir, "elastic", "elasticsearch", "main"); @@ -78,12 +78,15 @@ public void DiscoverUploadTargets_SingleEntry_MapsToPoolScopedKey() public void DiscoverUploadTargets_BranchWithDotsOrSlashes_MapsVerbatim(string branch, string expectedKey) { // language=yaml - AddChangelog("entry.yaml", """ + AddChangelog( + "entry.yaml", + """ title: New feature type: feature products: - product: elasticsearch - """); + """ + ); var targets = _service.DiscoverUploadTargets(_collector, _changelogDir, "elastic", "elasticsearch", branch); @@ -97,12 +100,15 @@ public void DiscoverUploadTargets_ExternalOrg_MapsToPoolScopedKey() { // An acquired company keeping its own GitHub org still gets a faithful pool. // language=yaml - AddChangelog("entry.yaml", """ + AddChangelog( + "entry.yaml", + """ title: New feature type: feature products: - product: widgets - """); + """ + ); var targets = _service.DiscoverUploadTargets(_collector, _changelogDir, "acme-corp", "widgets", "main"); @@ -117,7 +123,9 @@ public void DiscoverUploadTargets_EntryWithMultipleProducts_StillSingleRepoKey() // Option AD: entries are stored once per authoring repo, regardless of how many products they // list (or will later be consumed by). No per-product fan-out. // language=yaml - AddChangelog("fix.yaml", """ + AddChangelog( + "fix.yaml", + """ title: Cross-product fix type: bug-fix products: @@ -127,7 +135,8 @@ public void DiscoverUploadTargets_EntryWithMultipleProducts_StillSingleRepoKey() target: 9.2.0 prs: - "200" - """); + """ + ); var targets = _service.DiscoverUploadTargets(_collector, _changelogDir, "elastic", "kibana", "main"); @@ -141,12 +150,15 @@ public void DiscoverUploadTargets_EntryWithNoProducts_StillUploaded() // Author foreknowledge of consuming products is no longer required: an entry with no products is // still uploaded under the repo pool. // language=yaml - AddChangelog("noproducts.yaml", """ + AddChangelog( + "noproducts.yaml", + """ title: No products type: feature prs: - "400" - """); + """ + ); var targets = _service.DiscoverUploadTargets(_collector, _changelogDir, "elastic", "elasticsearch", "main"); @@ -159,12 +171,15 @@ public void DiscoverUploadTargets_EntryWithNoProducts_StillUploaded() public void DiscoverUploadTargets_MissingRepo_EmitsErrorAndReturnsEmpty() { // language=yaml - AddChangelog("entry.yaml", """ + AddChangelog( + "entry.yaml", + """ title: New feature type: feature products: - product: elasticsearch - """); + """ + ); var targets = _service.DiscoverUploadTargets(_collector, _changelogDir, "elastic", repo: null, "main"); @@ -176,12 +191,15 @@ public void DiscoverUploadTargets_MissingRepo_EmitsErrorAndReturnsEmpty() public void DiscoverUploadTargets_MissingOwner_EmitsErrorAndReturnsEmpty() { // language=yaml - AddChangelog("entry.yaml", """ + AddChangelog( + "entry.yaml", + """ title: New feature type: feature products: - product: elasticsearch - """); + """ + ); var targets = _service.DiscoverUploadTargets(_collector, _changelogDir, org: null, "elasticsearch", "main"); @@ -193,12 +211,15 @@ public void DiscoverUploadTargets_MissingOwner_EmitsErrorAndReturnsEmpty() public void DiscoverUploadTargets_MissingBranch_EmitsErrorAndReturnsEmpty() { // language=yaml - AddChangelog("entry.yaml", """ + AddChangelog( + "entry.yaml", + """ title: New feature type: feature products: - product: elasticsearch - """); + """ + ); var targets = _service.DiscoverUploadTargets(_collector, _changelogDir, "elastic", "elasticsearch", branch: null); @@ -214,12 +235,15 @@ public void DiscoverUploadTargets_MissingBranch_EmitsErrorAndReturnsEmpty() public void DiscoverUploadTargets_InvalidRepo_EmitsError(string repo) { // language=yaml - AddChangelog("entry.yaml", """ + AddChangelog( + "entry.yaml", + """ title: New feature type: feature products: - product: elasticsearch - """); + """ + ); var targets = _service.DiscoverUploadTargets(_collector, _changelogDir, "elastic", repo, "main"); @@ -235,12 +259,15 @@ public void DiscoverUploadTargets_InvalidRepo_EmitsError(string repo) public void DiscoverUploadTargets_InvalidOrg_EmitsError(string org) { // language=yaml - AddChangelog("entry.yaml", """ + AddChangelog( + "entry.yaml", + """ title: New feature type: feature products: - product: elasticsearch - """); + """ + ); var targets = _service.DiscoverUploadTargets(_collector, _changelogDir, org, "elasticsearch", "main"); @@ -257,12 +284,15 @@ public void DiscoverUploadTargets_InvalidOrg_EmitsError(string org) public void DiscoverUploadTargets_InvalidBranch_EmitsError(string branch) { // language=yaml - AddChangelog("entry.yaml", """ + AddChangelog( + "entry.yaml", + """ title: New feature type: feature products: - product: elasticsearch - """); + """ + ); var targets = _service.DiscoverUploadTargets(_collector, _changelogDir, "elastic", "elasticsearch", branch); @@ -283,23 +313,29 @@ public void DiscoverUploadTargets_EmptyDirectory_ReturnsEmpty() public void DiscoverUploadTargets_MultipleFiles_DiscoversAllUnderRepo() { // language=yaml - AddChangelog("first.yaml", """ + AddChangelog( + "first.yaml", + """ title: First type: feature products: - product: elasticsearch prs: - "1" - """); + """ + ); // language=yaml - AddChangelog("second.yaml", """ + AddChangelog( + "second.yaml", + """ title: Second type: bug-fix products: - product: kibana prs: - "2" - """); + """ + ); var targets = _service.DiscoverUploadTargets(_collector, _changelogDir, "elastic", "elasticsearch", "main"); @@ -316,14 +352,17 @@ public void DiscoverUploadTargets_MultipleFiles_DiscoversAllUnderRepo() public void DiscoverUploadTargets_RepoWithHyphensDotsUnderscores_Accepted(string repo) { // language=yaml - AddChangelog("entry.yaml", """ + AddChangelog( + "entry.yaml", + """ title: Hyphenated type: feature products: - product: elasticsearch prs: - "600" - """); + """ + ); var targets = _service.DiscoverUploadTargets(_collector, _changelogDir, "elastic", repo, "main"); @@ -337,7 +376,9 @@ public void DiscoverUploadTargets_RepoWithHyphensDotsUnderscores_Accepted(string public async Task Upload_WithValidChangelogs_UploadsToS3() { // language=yaml - AddChangelog("entry.yaml", """ + AddChangelog( + "entry.yaml", + """ title: New feature type: feature products: @@ -345,13 +386,14 @@ public async Task Upload_WithValidChangelogs_UploadsToS3() target: 9.2.0 prs: - "100" - """); + """ + ); - A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)) - .Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); + A.CallTo( + () => _s3Client.GetObjectMetadataAsync(A._, A._) + ).Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); - A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) - .Returns(new PutObjectResponse()); + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)).Returns(new PutObjectResponse()); var args = new ChangelogUploadArguments { @@ -369,17 +411,24 @@ public async Task Upload_WithValidChangelogs_UploadsToS3() result.Should().BeTrue(); _collector.Errors.Should().Be(0); - A.CallTo(() => _s3Client.PutObjectAsync( - A.That.Matches(r => r.Key == "changelog/elastic/elasticsearch/main/entry.yaml" && r.BucketName == "test-bucket"), - A._ - )).MustHaveHappenedOnceExactly(); + A.CallTo( + () => + _s3Client.PutObjectAsync( + A.That.Matches( + r => r.Key == "changelog/elastic/elasticsearch/main/entry.yaml" && r.BucketName == "test-bucket" + ), + A._ + ) + ).MustHaveHappenedOnceExactly(); } [Fact] public async Task Upload_ChangelogWithoutRepo_FailsWithoutS3Calls() { // language=yaml - AddChangelog("entry.yaml", """ + AddChangelog( + "entry.yaml", + """ title: New feature type: feature products: @@ -387,7 +436,8 @@ public async Task Upload_ChangelogWithoutRepo_FailsWithoutS3Calls() target: 9.2.0 prs: - "100" - """); + """ + ); var args = new ChangelogUploadArguments { @@ -405,8 +455,7 @@ public async Task Upload_ChangelogWithoutRepo_FailsWithoutS3Calls() result.Should().BeFalse(); _collector.Errors.Should().BeGreaterThan(0); - A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) - .MustNotHaveHappened(); + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)).MustNotHaveHappened(); } [Fact] @@ -428,28 +477,33 @@ public async Task Upload_EmptyDirectory_ReturnsTrue() result.Should().BeTrue(); _collector.Errors.Should().Be(0); - A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) - .MustNotHaveHappened(); + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)).MustNotHaveHappened(); } [Fact] public async Task Upload_WithFailedUpload_ReturnsFalseAndEmitsError() { // language=yaml - AddChangelog("fail.yaml", """ + AddChangelog( + "fail.yaml", + """ title: Will fail type: feature products: - product: elasticsearch prs: - "700" - """); + """ + ); - A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)) - .Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); + A.CallTo( + () => _s3Client.GetObjectMetadataAsync(A._, A._) + ).Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); - A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) - .Throws(new AmazonS3Exception("Access Denied") { StatusCode = HttpStatusCode.Forbidden }); + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)).Throws(new AmazonS3Exception( + "Access Denied" + ) + { StatusCode = HttpStatusCode.Forbidden }); var args = new ChangelogUploadArguments { @@ -471,14 +525,17 @@ public async Task Upload_WithFailedUpload_ReturnsFalseAndEmitsError() [Fact] public async Task Upload_ElasticsearchTarget_SkipsWithoutS3Calls() { - AddChangelog("skip.yaml", """ + AddChangelog( + "skip.yaml", + """ title: Ignored type: feature products: - product: elasticsearch prs: - "800" - """); + """ + ); var args = new ChangelogUploadArguments { @@ -492,8 +549,7 @@ public async Task Upload_ElasticsearchTarget_SkipsWithoutS3Calls() result.Should().BeTrue(); - A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) - .MustNotHaveHappened(); + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)).MustNotHaveHappened(); } [Fact] @@ -503,7 +559,10 @@ public async Task Upload_BundleArtifactType_UploadsToS3() _mockFileSystem.Directory.CreateDirectory(bundleDir); var path = _mockFileSystem.Path.Join(bundleDir, "elasticsearch-9.2.0.yaml"); // language=yaml - _mockFileSystem.AddFile(path, new MockFileData(""" + _mockFileSystem.AddFile( + path, + new MockFileData( + """ products: - product: elasticsearch target: 9.2.0 @@ -518,13 +577,15 @@ public async Task Upload_BundleArtifactType_UploadsToS3() title: New feature prs: - https://github.com/elastic/elasticsearch/pull/1234 - """)); + """ + ) + ); - A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)) - .Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); + A.CallTo( + () => _s3Client.GetObjectMetadataAsync(A._, A._) + ).Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); - A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) - .Returns(new PutObjectResponse()); + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)).Returns(new PutObjectResponse()); var args = new ChangelogUploadArguments { @@ -539,10 +600,15 @@ public async Task Upload_BundleArtifactType_UploadsToS3() result.Should().BeTrue(); _collector.Errors.Should().Be(0); - A.CallTo(() => _s3Client.PutObjectAsync( - A.That.Matches(r => r.Key == "bundle/elasticsearch/elasticsearch-9.2.0.yaml" && r.BucketName == "test-bucket"), - A._ - )).MustHaveHappenedOnceExactly(); + A.CallTo( + () => + _s3Client.PutObjectAsync( + A.That.Matches( + r => r.Key == "bundle/elasticsearch/elasticsearch-9.2.0.yaml" && r.BucketName == "test-bucket" + ), + A._ + ) + ).MustHaveHappenedOnceExactly(); } [Fact] @@ -551,7 +617,10 @@ public void DiscoverBundleUploadTargets_MapsToArtifactRootKey() var bundleDir = _mockFileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "releases"); _mockFileSystem.Directory.CreateDirectory(bundleDir); // language=yaml - _mockFileSystem.AddFile(_mockFileSystem.Path.Join(bundleDir, "elasticsearch-9.2.0.yaml"), new MockFileData(""" + _mockFileSystem.AddFile( + _mockFileSystem.Path.Join(bundleDir, "elasticsearch-9.2.0.yaml"), + new MockFileData( + """ products: - product: elasticsearch target: 9.2.0 @@ -565,7 +634,9 @@ public void DiscoverBundleUploadTargets_MapsToArtifactRootKey() title: Fixed crash on startup prs: - https://github.com/elastic/elasticsearch/pull/5678 - """)); + """ + ) + ); var targets = _service.DiscoverBundleUploadTargets(_collector, bundleDir); @@ -580,7 +651,10 @@ public void DiscoverBundleUploadTargets_MultipleProducts_CreatesTargetPerProduct var bundleDir = _mockFileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "releases"); _mockFileSystem.Directory.CreateDirectory(bundleDir); // language=yaml - _mockFileSystem.AddFile(_mockFileSystem.Path.Join(bundleDir, "stack-9.2.0.yaml"), new MockFileData(""" + _mockFileSystem.AddFile( + _mockFileSystem.Path.Join(bundleDir, "stack-9.2.0.yaml"), + new MockFileData( + """ products: - product: elasticsearch target: 9.2.0 @@ -596,7 +670,9 @@ public void DiscoverBundleUploadTargets_MultipleProducts_CreatesTargetPerProduct title: Cross-product improvement prs: - https://github.com/elastic/elasticsearch/pull/9999 - """)); + """ + ) + ); var targets = _service.DiscoverBundleUploadTargets(_collector, bundleDir); @@ -623,7 +699,10 @@ public void DiscoverBundleUploadTargets_AmendWithProducts_MapsToProductKey() _mockFileSystem.Directory.CreateDirectory(bundleDir); // Amend materialized by a current docs-builder: it carries the parent's complete products. // language=yaml - _mockFileSystem.AddFile(_mockFileSystem.Path.Join(bundleDir, "elasticsearch-9.3.0.amend-1.yaml"), new MockFileData(""" + _mockFileSystem.AddFile( + _mockFileSystem.Path.Join(bundleDir, "elasticsearch-9.3.0.amend-1.yaml"), + new MockFileData( + """ products: - product: elasticsearch target: 9.3.0 @@ -635,7 +714,9 @@ public void DiscoverBundleUploadTargets_AmendWithProducts_MapsToProductKey() checksum: c0ffee type: enhancement title: Late addition - """)); + """ + ) + ); var targets = _service.DiscoverBundleUploadTargets(_collector, bundleDir); @@ -651,7 +732,10 @@ public void DiscoverBundleUploadTargets_LegacyAmendWithoutProducts_DerivesDestin var bundleDir = _mockFileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "releases"); _mockFileSystem.Directory.CreateDirectory(bundleDir); // language=yaml - _mockFileSystem.AddFile(_mockFileSystem.Path.Join(bundleDir, "stack-9.3.0.yaml"), new MockFileData(""" + _mockFileSystem.AddFile( + _mockFileSystem.Path.Join(bundleDir, "stack-9.3.0.yaml"), + new MockFileData( + """ products: - product: elasticsearch target: 9.3.0 @@ -665,15 +749,22 @@ public void DiscoverBundleUploadTargets_LegacyAmendWithoutProducts_DerivesDestin checksum: deadbeef type: bug-fix title: To be retracted - """)); + """ + ) + ); // Amend published before products were copied from the parent: exclusion only, no products. // language=yaml - _mockFileSystem.AddFile(_mockFileSystem.Path.Join(bundleDir, "stack-9.3.0.amend-1.yaml"), new MockFileData(""" + _mockFileSystem.AddFile( + _mockFileSystem.Path.Join(bundleDir, "stack-9.3.0.amend-1.yaml"), + new MockFileData( + """ exclude-entries: - file: name: 1-old.yaml checksum: deadbeef - """)); + """ + ) + ); var targets = _service.DiscoverBundleUploadTargets(_collector, bundleDir); @@ -692,12 +783,17 @@ public void DiscoverBundleUploadTargets_OrphanLegacyAmend_WarnsAndSkips() // No parent bundle next to the amend, and the amend declares no products: the destination // cannot be derived, but the skip must be visible instead of silent. // language=yaml - _mockFileSystem.AddFile(_mockFileSystem.Path.Join(bundleDir, "stack-9.3.0.amend-1.yaml"), new MockFileData(""" + _mockFileSystem.AddFile( + _mockFileSystem.Path.Join(bundleDir, "stack-9.3.0.amend-1.yaml"), + new MockFileData( + """ exclude-entries: - file: name: 1-old.yaml checksum: deadbeef - """)); + """ + ) + ); var targets = _service.DiscoverBundleUploadTargets(_collector, bundleDir); @@ -712,7 +808,10 @@ public async Task Upload_BundleArtifactType_DoesNotWriteRegistry() var bundleDir = _mockFileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "releases"); _mockFileSystem.Directory.CreateDirectory(bundleDir); // language=yaml - _mockFileSystem.AddFile(_mockFileSystem.Path.Join(bundleDir, "9.3.0.yaml"), new MockFileData(""" + _mockFileSystem.AddFile( + _mockFileSystem.Path.Join(bundleDir, "9.3.0.yaml"), + new MockFileData( + """ products: - product: elasticsearch target: 9.3.0 @@ -724,14 +823,18 @@ public async Task Upload_BundleArtifactType_DoesNotWriteRegistry() checksum: c0ffee type: enhancement title: Sample - """)); - - A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)) - .Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); - A.CallTo(() => _s3Client.GetObjectAsync(A._, A._)) - .Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); - A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) - .Returns(new PutObjectResponse()); + """ + ) + ); + + A.CallTo( + () => _s3Client.GetObjectMetadataAsync(A._, A._) + ).Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); + A.CallTo(() => _s3Client.GetObjectAsync(A._, A._)).Throws(new AmazonS3Exception("Not Found") + { + StatusCode = HttpStatusCode.NotFound + }); + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)).Returns(new PutObjectResponse()); var args = new ChangelogUploadArguments { @@ -746,24 +849,32 @@ public async Task Upload_BundleArtifactType_DoesNotWriteRegistry() result.Should().BeTrue(); _collector.Errors.Should().Be(0); - A.CallTo(() => _s3Client.PutObjectAsync( - A.That.Matches(r => r.Key == "bundle/elasticsearch/9.3.0.yaml"), - A._ - )).MustHaveHappenedOnceExactly(); + A.CallTo( + () => + _s3Client.PutObjectAsync( + A.That.Matches(r => r.Key == "bundle/elasticsearch/9.3.0.yaml"), + A._ + ) + ).MustHaveHappenedOnceExactly(); // The scrubber Lambda is the sole registry producer (docs-eng-team#688 Phase 3): // uploads write YAML objects only, never a registry.json. - A.CallTo(() => _s3Client.PutObjectAsync( - A.That.Matches(r => r.Key.EndsWith("registry.json", StringComparison.Ordinal)), - A._ - )).MustNotHaveHappened(); + A.CallTo( + () => + _s3Client.PutObjectAsync( + A.That.Matches(r => r.Key.EndsWith("registry.json", StringComparison.Ordinal)), + A._ + ) + ).MustNotHaveHappened(); } [Fact] public async Task Upload_ChangelogArtifactType_DoesNotWriteRegistry() { // language=yaml - AddChangelog("entry.yaml", """ + AddChangelog( + "entry.yaml", + """ title: Plain entry type: feature products: @@ -771,14 +882,17 @@ public async Task Upload_ChangelogArtifactType_DoesNotWriteRegistry() target: 9.2.0 prs: - "100" - """); + """ + ); - A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)) - .Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); - A.CallTo(() => _s3Client.GetObjectAsync(A._, A._)) - .Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); - A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) - .Returns(new PutObjectResponse()); + A.CallTo( + () => _s3Client.GetObjectMetadataAsync(A._, A._) + ).Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); + A.CallTo(() => _s3Client.GetObjectAsync(A._, A._)).Throws(new AmazonS3Exception("Not Found") + { + StatusCode = HttpStatusCode.NotFound + }); + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)).Returns(new PutObjectResponse()); var args = new ChangelogUploadArguments { @@ -795,16 +909,22 @@ public async Task Upload_ChangelogArtifactType_DoesNotWriteRegistry() result.Should().BeTrue(); - A.CallTo(() => _s3Client.PutObjectAsync( - A.That.Matches(r => r.Key == "changelog/elastic/elasticsearch/main/entry.yaml"), - A._ - )).MustHaveHappenedOnceExactly(); + A.CallTo( + () => + _s3Client.PutObjectAsync( + A.That.Matches(r => r.Key == "changelog/elastic/elasticsearch/main/entry.yaml"), + A._ + ) + ).MustHaveHappenedOnceExactly(); // The scrubber Lambda is the sole registry producer (docs-eng-team#688 Phase 3): // uploads write YAML objects only, never a registry.json. - A.CallTo(() => _s3Client.PutObjectAsync( - A.That.Matches(r => r.Key.EndsWith("registry.json", StringComparison.Ordinal)), - A._ - )).MustNotHaveHappened(); + A.CallTo( + () => + _s3Client.PutObjectAsync( + A.That.Matches(r => r.Key.EndsWith("registry.json", StringComparison.Ordinal)), + A._ + ) + ).MustNotHaveHappened(); } } diff --git a/tests/Elastic.Changelog.Tests/Utilities/ChangelogUtf8NormalizationTests.cs b/tests/Elastic.Changelog.Tests/Utilities/ChangelogUtf8NormalizationTests.cs index ce94db676a..301c6a7f32 100644 --- a/tests/Elastic.Changelog.Tests/Utilities/ChangelogUtf8NormalizationTests.cs +++ b/tests/Elastic.Changelog.Tests/Utilities/ChangelogUtf8NormalizationTests.cs @@ -71,8 +71,7 @@ public void StripLeadingUtf8BomChar_StringWithConsecutiveLeadingBoms_RemovesAllL { const string content = "type: feature\ntitle: Test"; // Two consecutive BOM characters at the start - var input = ChangelogUtf8Normalization.Utf8BomChar.ToString() + - ChangelogUtf8Normalization.Utf8BomChar + content; + var input = ChangelogUtf8Normalization.Utf8BomChar.ToString() + ChangelogUtf8Normalization.Utf8BomChar + content; var result = ChangelogUtf8Normalization.StripLeadingUtf8BomChar(input); @@ -84,9 +83,10 @@ public void StripLeadingUtf8BomChar_StringWithThreeConsecutiveLeadingBoms_Remove { const string content = "type: feature\ntitle: Test"; // Three consecutive BOM characters at the start (edge case test) - var input = ChangelogUtf8Normalization.Utf8BomChar.ToString() + - ChangelogUtf8Normalization.Utf8BomChar + - ChangelogUtf8Normalization.Utf8BomChar + content; + var input = ChangelogUtf8Normalization.Utf8BomChar.ToString() + + ChangelogUtf8Normalization.Utf8BomChar + + ChangelogUtf8Normalization.Utf8BomChar + + content; var result = ChangelogUtf8Normalization.StripLeadingUtf8BomChar(input); diff --git a/tests/Elastic.Changelog.Tests/Utilities/OutputSanitizerTests.cs b/tests/Elastic.Changelog.Tests/Utilities/OutputSanitizerTests.cs index 9add211c21..58bf190627 100644 --- a/tests/Elastic.Changelog.Tests/Utilities/OutputSanitizerTests.cs +++ b/tests/Elastic.Changelog.Tests/Utilities/OutputSanitizerTests.cs @@ -10,25 +10,20 @@ namespace Elastic.Changelog.Tests.Utilities; public class OutputSanitizerTests { [Fact] - public void NullInput_ReturnsEmpty() => - OutputSanitizer.SanitizeForOutput(null, 100).Should().Be(string.Empty); + public void NullInput_ReturnsEmpty() => OutputSanitizer.SanitizeForOutput(null, 100).Should().Be(string.Empty); [Fact] - public void EmptyInput_ReturnsEmpty() => - OutputSanitizer.SanitizeForOutput(string.Empty, 100).Should().Be(string.Empty); + public void EmptyInput_ReturnsEmpty() => OutputSanitizer.SanitizeForOutput(string.Empty, 100).Should().Be(string.Empty); [Fact] - public void ZeroMaxLength_ReturnsEmpty() => - OutputSanitizer.SanitizeForOutput("anything", 0).Should().Be(string.Empty); + public void ZeroMaxLength_ReturnsEmpty() => OutputSanitizer.SanitizeForOutput("anything", 0).Should().Be(string.Empty); [Fact] - public void NegativeMaxLength_ReturnsEmpty() => - OutputSanitizer.SanitizeForOutput("anything", -1).Should().Be(string.Empty); + public void NegativeMaxLength_ReturnsEmpty() => OutputSanitizer.SanitizeForOutput("anything", -1).Should().Be(string.Empty); [Fact] public void PlainAscii_PassesThrough() => - OutputSanitizer.SanitizeForOutput("Add new search API", 100) - .Should().Be("Add new search API"); + OutputSanitizer.SanitizeForOutput("Add new search API", 100).Should().Be("Add new search API"); [Fact] public void PreservesNewlinesAndTabs() @@ -38,12 +33,10 @@ public void PreservesNewlinesAndTabs() } [Fact] - public void StripsNullBytes() => - OutputSanitizer.SanitizeForOutput("hello\0world", 100).Should().Be("helloworld"); + public void StripsNullBytes() => OutputSanitizer.SanitizeForOutput("hello\0world", 100).Should().Be("helloworld"); [Fact] - public void StripsCarriageReturn() => - OutputSanitizer.SanitizeForOutput("line1\r\nline2", 100).Should().Be("line1\nline2"); + public void StripsCarriageReturn() => OutputSanitizer.SanitizeForOutput("line1\r\nline2", 100).Should().Be("line1\nline2"); [Theory] [InlineData('\u0001')] @@ -99,8 +92,7 @@ public void GitHubOutputDelimiterMimic_HasControlCharsStripped() public void RealisticPrTitle_FitsWithinTitleCap() { var input = "[7.17] Backport: improve search aggregation performance for large indices"; - OutputSanitizer.SanitizeForOutput(input, OutputSanitizer.TitleMaxLength) - .Should().Be(input); + OutputSanitizer.SanitizeForOutput(input, OutputSanitizer.TitleMaxLength).Should().Be(input); } [Fact] @@ -108,6 +100,7 @@ public void HugeBody_TruncatedToDescriptionCap() { var input = new string('x', OutputSanitizer.DescriptionMaxLength * 2); OutputSanitizer.SanitizeForOutput(input, OutputSanitizer.DescriptionMaxLength) - .Should().HaveLength(OutputSanitizer.DescriptionMaxLength); + .Should() + .HaveLength(OutputSanitizer.DescriptionMaxLength); } } diff --git a/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs index 111d5bc9a9..5d6d1baa74 100644 --- a/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs +++ b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs @@ -39,13 +39,15 @@ public class AgentBuilderStreamTransformerTests { private readonly AgentBuilderStreamTransformer _transformer; - public AgentBuilderStreamTransformerTests() => _transformer = new AgentBuilderStreamTransformer(NullLogger.Instance); + public AgentBuilderStreamTransformerTests() => + _transformer = new AgentBuilderStreamTransformer(NullLogger.Instance); [Fact] public async Task TransformAsyncWithRealAgentBuilderPayloadParsesAllEventTypes() { // Arrange - Real Agent Builder SSE stream - var sseData = """ + var sseData = + """ event: conversation_id_set data: {"data":{"conversation_id":"360222c5-76aa-405a-8316-703e1061b621"}} @@ -123,7 +125,8 @@ public async Task TransformAsyncWithRealAgentBuilderPayloadParsesAllEventTypes() public async Task TransformAsyncWithKeepAliveCommentsSkipsThem() { // Arrange - var sseData = """ + var sseData = + """ : 000000000000000000 event: message_chunk @@ -151,7 +154,8 @@ public async Task TransformAsyncWithKeepAliveCommentsSkipsThem() public async Task TransformAsyncWithMultilineDataFieldsAccumulatesCorrectly() { // Arrange - var sseData = """ + var sseData = + """ event: message_chunk data: {"data": data: {"text_chunk": @@ -165,26 +169,26 @@ public async Task TransformAsyncWithMultilineDataFieldsAccumulatesCorrectly() var outputStream = await _transformer.TransformAsync(inputStream, generatedConversationId: null, null, CancellationToken.None); var events = await StreamTransformerTestHelpers.ParseAskAiEventsAsync(outputStream); - // Assert - This test has malformed SSE data (missing proper blank line terminator) // In a real scenario with proper SSE formatting, this would work // For now, skip this test or mark as known limitation events.Should().HaveCountGreaterThanOrEqualTo(0); } - } public class LlmGatewayStreamTransformerTests { private readonly LlmGatewayStreamTransformer _transformer; - public LlmGatewayStreamTransformerTests() => _transformer = new LlmGatewayStreamTransformer(NullLogger.Instance); + public LlmGatewayStreamTransformerTests() => + _transformer = new LlmGatewayStreamTransformer(NullLogger.Instance); [Fact] public async Task TransformAsyncWithRealLlmGatewayPayloadParsesAllEventTypes() { // Arrange - Real LLM Gateway SSE stream - var sseData = """ + var sseData = + """ event: agent_stream_output data: [null, {"type":"agent_start","id":"1","timestamp":1234567890,"data":{}}] @@ -223,7 +227,6 @@ public async Task TransformAsyncWithRealLlmGatewayPayloadParsesAllEventTypes() var convStart = events[0] as AskAiEvent.ConversationStart; convStart!.ConversationId.Should().Be(testConversationId); - // Event 2: ai_message_chunk (first) events[1].Should().BeOfType(); var chunk1 = events[1] as AskAiEvent.MessageChunk; @@ -260,7 +263,8 @@ public async Task TransformAsyncWithRealLlmGatewayPayloadParsesAllEventTypes() public async Task TransformAsyncWithEmptyDataLinesSkipsThem() { // Arrange - var sseData = """ + var sseData = + """ event: agent_stream_output data: @@ -292,7 +296,8 @@ public async Task TransformAsyncWithEmptyDataLinesSkipsThem() public async Task TransformAsyncSkipsModelLifecycleEvents() { // Arrange - var sseData = """ + var sseData = + """ data: [null, {"type":"chat_model_start","id":"1","timestamp":1234567890,"data":{}}] data: [null, {"type":"ai_message_chunk","id":"2","timestamp":1234567891,"data":{"content":"test"}}] @@ -313,7 +318,6 @@ public async Task TransformAsyncSkipsModelLifecycleEvents() events[0].Should().BeOfType(); events[1].Should().BeOfType(); } - } /// @@ -357,7 +361,8 @@ public static TheoryData StreamTransformerTe public async Task TransformAsyncWhenIsNewConversationEmitsConversationStartEvent( string transformerName, IStreamTransformer transformer, - string sseData) + string sseData + ) { // Arrange var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(sseData)); @@ -368,12 +373,15 @@ public async Task TransformAsyncWhenIsNewConversationEmitsConversationStartEvent var events = await StreamTransformerTestHelpers.ParseAskAiEventsAsync(outputStream); // Assert - Should have ConversationStart event - events.Should().ContainSingle(e => e is AskAiEvent.ConversationStart, - $"{transformerName} should emit ConversationStart when isNewConversation is true"); + events.Should().ContainSingle( + e => e is AskAiEvent.ConversationStart, + $"{transformerName} should emit ConversationStart when isNewConversation is true" + ); var conversationStart = events.OfType().First(); - conversationStart.ConversationId.Should().NotBeNullOrEmpty( - $"{transformerName} should have a non-empty conversation ID in ConversationStart event"); + conversationStart.ConversationId + .Should() + .NotBeNullOrEmpty($"{transformerName} should have a non-empty conversation ID in ConversationStart event"); } [Theory] @@ -381,7 +389,8 @@ public async Task TransformAsyncWhenIsNewConversationEmitsConversationStartEvent public async Task TransformAsyncConversationStartEventHasValidTimestamp( string transformerName, IStreamTransformer transformer, - string sseData) + string sseData + ) { // Arrange var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(sseData)); @@ -400,19 +409,14 @@ public async Task TransformAsyncConversationStartEventHasValidTimestamp( // Assert var conversationStart = events.OfType().FirstOrDefault(); - conversationStart.Should().NotBeNull( - $"{transformerName} should emit ConversationStart event"); + conversationStart.Should().NotBeNull($"{transformerName} should emit ConversationStart event"); - conversationStart.Timestamp.Should().BeGreaterThan(0, - $"{transformerName} ConversationStart should have a valid timestamp"); + conversationStart.Timestamp.Should().BeGreaterThan(0, $"{transformerName} ConversationStart should have a valid timestamp"); } [Theory] [MemberData(nameof(StreamTransformerTestCases))] - public async Task TransformAsyncConversationStartEventHasValidId( - string transformerName, - IStreamTransformer transformer, - string sseData) + public async Task TransformAsyncConversationStartEventHasValidId(string transformerName, IStreamTransformer transformer, string sseData) { // Arrange var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(sseData)); @@ -431,12 +435,9 @@ public async Task TransformAsyncConversationStartEventHasValidId( // Assert var conversationStart = events.OfType().FirstOrDefault(); - conversationStart.Should().NotBeNull( - $"{transformerName} should emit ConversationStart event"); + conversationStart.Should().NotBeNull($"{transformerName} should emit ConversationStart event"); - conversationStart.Id.Should().NotBeNullOrEmpty( - $"{transformerName} ConversationStart should have a non-empty event ID"); + conversationStart.Id.Should().NotBeNullOrEmpty($"{transformerName} ConversationStart should have a non-empty event ID"); } - } #pragma warning restore xUnit1045 diff --git a/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/Search/StringHighlightExtensionsTests.cs b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/Search/StringHighlightExtensionsTests.cs index f12c17a450..ceb22a469a 100644 --- a/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/Search/StringHighlightExtensionsTests.cs +++ b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/Search/StringHighlightExtensionsTests.cs @@ -458,10 +458,7 @@ public void SynonymsEmptyDictionaryHighlightsOnlyTokens() public void SynonymsHighlightsBothTokenAndSynonym() { var text = "Kubernetes and k8s are the same thing"; - var synonyms = new Dictionary - { - ["kubernetes"] = ["k8s"] - }; + var synonyms = new Dictionary { ["kubernetes"] = ["k8s"] }; var result = text.HighlightTokens(["kubernetes"], synonyms); result.Should().Be("Kubernetes and k8s are the same thing"); @@ -471,10 +468,7 @@ public void SynonymsHighlightsBothTokenAndSynonym() public void SynonymsHighlightsMultipleSynonyms() { var text = "Use Elasticsearch or ES or elastic for search"; - var synonyms = new Dictionary - { - ["elasticsearch"] = ["es", "elastic"] - }; + var synonyms = new Dictionary { ["elasticsearch"] = ["es", "elastic"] }; var result = text.HighlightTokens(["elasticsearch"], synonyms); result.Should().Be("Use Elasticsearch or ES or elastic for search"); @@ -484,10 +478,7 @@ public void SynonymsHighlightsMultipleSynonyms() public void SynonymsCaseInsensitiveLookup() { var text = "K8S is short for kubernetes"; - var synonyms = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["kubernetes"] = ["k8s"] - }; + var synonyms = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["kubernetes"] = ["k8s"] }; var result = text.HighlightTokens(["KUBERNETES"], synonyms); result.Should().Be("K8S is short for kubernetes"); @@ -497,10 +488,7 @@ public void SynonymsCaseInsensitiveLookup() public void SynonymsTokenNotInDictionary() { var text = "Logstash is a pipeline tool"; - var synonyms = new Dictionary - { - ["elasticsearch"] = ["es"] - }; + var synonyms = new Dictionary { ["elasticsearch"] = ["es"] }; var result = text.HighlightTokens(["logstash"], synonyms); result.Should().Be("Logstash is a pipeline tool"); @@ -510,10 +498,7 @@ public void SynonymsTokenNotInDictionary() public void SynonymsEmptySynonymArrayIgnored() { var text = "Elasticsearch is powerful"; - var synonyms = new Dictionary - { - ["elasticsearch"] = [] - }; + var synonyms = new Dictionary { ["elasticsearch"] = [] }; var result = text.HighlightTokens(["elasticsearch"], synonyms); result.Should().Be("Elasticsearch is powerful"); @@ -523,10 +508,7 @@ public void SynonymsEmptySynonymArrayIgnored() public void SynonymsEmptyStringsInArrayIgnored() { var text = "Kubernetes and k8s cluster"; - var synonyms = new Dictionary - { - ["kubernetes"] = ["", "k8s", null!, ""] - }; + var synonyms = new Dictionary { ["kubernetes"] = ["", "k8s", null!, ""] }; var result = text.HighlightTokens(["kubernetes"], synonyms); result.Should().Be("Kubernetes and k8s cluster"); @@ -551,10 +533,7 @@ public void SynonymsMultipleTokensWithDifferentSynonyms() public void SynonymsAlreadyHighlightedSynonymNotDoubleHighlighted() { var text = "Use k8s for Kubernetes deployments"; - var synonyms = new Dictionary - { - ["kubernetes"] = ["k8s"] - }; + var synonyms = new Dictionary { ["kubernetes"] = ["k8s"] }; var result = text.HighlightTokens(["kubernetes"], synonyms); result.Should().Be("Use k8s for Kubernetes deployments"); @@ -579,10 +558,7 @@ public void SynonymsBiDirectionalLookup() public void SynonymsMultipleOccurrencesOfSynonym() { var text = "k8s here and k8s there but also kubernetes"; - var synonyms = new Dictionary - { - ["kubernetes"] = ["k8s"] - }; + var synonyms = new Dictionary { ["kubernetes"] = ["k8s"] }; var result = text.HighlightTokens(["kubernetes"], synonyms); result.Should().Be("k8s here and k8s there but also kubernetes"); @@ -592,10 +568,7 @@ public void SynonymsMultipleOccurrencesOfSynonym() public void SynonymsRealWorldElasticSearchExample() { var text = "Configure ES cluster settings in Elasticsearch for elastic cloud"; - var synonyms = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["elasticsearch"] = ["es", "elastic"] - }; + var synonyms = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["elasticsearch"] = ["es", "elastic"] }; var result = text.HighlightTokens(["elasticsearch"], synonyms); result.Should().Be("Configure ES cluster settings in Elasticsearch for elastic cloud"); @@ -605,10 +578,7 @@ public void SynonymsRealWorldElasticSearchExample() public void SynonymsRealWorldMachineLearningExample() { var text = "ML models for machine learning in the ml node"; - var synonyms = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["machine learning"] = ["ml"] - }; + var synonyms = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["machine learning"] = ["ml"] }; var result = text.HighlightTokens(["machine learning"], synonyms); // Note: "machine learning" as a token matches the phrase, ml is a synonym @@ -619,10 +589,7 @@ public void SynonymsRealWorldMachineLearningExample() public void SynonymsSynonymInsideMarkTagNotHighlighted() { var text = "kubernetes and k8s are popular"; - var synonyms = new Dictionary - { - ["kubernetes"] = ["k8s"] - }; + var synonyms = new Dictionary { ["kubernetes"] = ["k8s"] }; var result = text.HighlightTokens(["kubernetes"], synonyms); // Both kubernetes and k8s are inside mark tag, should not be double-highlighted @@ -633,10 +600,7 @@ public void SynonymsSynonymInsideMarkTagNotHighlighted() public void SynonymsMixedHighlightedAndUnhighlightedSynonyms() { var text = "k8s and kubernetes cluster"; - var synonyms = new Dictionary - { - ["kubernetes"] = ["k8s"] - }; + var synonyms = new Dictionary { ["kubernetes"] = ["k8s"] }; var result = text.HighlightTokens(["kubernetes"], synonyms); result.Should().Be("k8s and kubernetes cluster"); @@ -646,10 +610,7 @@ public void SynonymsMixedHighlightedAndUnhighlightedSynonyms() public void SynonymsPreservesOriginalCaseForSynonym() { var text = "Use K8S for your deployments"; - var synonyms = new Dictionary - { - ["kubernetes"] = ["k8s"] - }; + var synonyms = new Dictionary { ["kubernetes"] = ["k8s"] }; var result = text.HighlightTokens(["kubernetes"], synonyms); // Original case "K8S" should be preserved in the highlight @@ -660,10 +621,7 @@ public void SynonymsPreservesOriginalCaseForSynonym() public void SynonymsWithSpecialCharacters() { var text = "Use ES|QL or esql for queries"; - var synonyms = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["esql"] = ["ES|QL"] - }; + var synonyms = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["esql"] = ["ES|QL"] }; var result = text.HighlightTokens(["esql"], synonyms); result.Should().Be("Use ES|QL or esql for queries"); @@ -674,10 +632,7 @@ public void SynonymsPartialMatchNotHighlighted() { // Synonym "k8s" should not match "ak8s" (middle of word) but should match "k8ss" (starts with k8s) var text = "k8ss is not k8s and ak8s is wrong"; - var synonyms = new Dictionary - { - ["kubernetes"] = ["k8s"] - }; + var synonyms = new Dictionary { ["kubernetes"] = ["k8s"] }; var result = text.HighlightTokens(["kubernetes"], synonyms); // With starts-with highlighting: @@ -691,10 +646,7 @@ public void SynonymsPartialMatchNotHighlighted() public void SynonymsHardReplacements() { var text = "ES|QL is esql and not EQL"; - var synonyms = new Dictionary - { - ["esql"] = ["es|ql => esql"] - }; + var synonyms = new Dictionary { ["esql"] = ["es|ql => esql"] }; var result = text.HighlightTokens(["es|ql"], synonyms); // k8s within k8ss and ak8s will be highlighted since it's a substring match diff --git a/tests/Elastic.Documentation.Api.Infrastructure.Tests/Caching/DistributedCacheTests.cs b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Caching/DistributedCacheTests.cs index b487c60d36..7694cfb803 100644 --- a/tests/Elastic.Documentation.Api.Infrastructure.Tests/Caching/DistributedCacheTests.cs +++ b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Caching/DistributedCacheTests.cs @@ -85,7 +85,10 @@ public async Task GetAsyncWhenL1HitDoesNotCallL2Again() // Arrange var fakeL2 = A.Fake(); var cache = new MultiLayerCache(fakeL2, NullLogger.Instance); - var uniqueKey = CacheKey.Create("test", $"test-key-{Guid.NewGuid()}"); // Use unique key to avoid L1 cache pollution from other tests + var uniqueKey = CacheKey.Create( + "test", + $"test-key-{Guid.NewGuid()}" + ); // Use unique key to avoid L1 cache pollution from other tests // Pre-populate L1 by setting a value await cache.SetAsync(uniqueKey, "value", TimeSpan.FromMinutes(1), TestContext.Current.CancellationToken); @@ -98,8 +101,7 @@ public async Task GetAsyncWhenL1HitDoesNotCallL2Again() result1.Should().Be("value"); result2.Should().Be("value"); // L2 should only be called once (during SetAsync), not on subsequent Gets - A.CallTo(() => fakeL2.GetAsync(A._, A._)) - .MustNotHaveHappened(); + A.CallTo(() => fakeL2.GetAsync(A._, A._)).MustNotHaveHappened(); } [Fact] @@ -107,9 +109,11 @@ public async Task GetAsyncWhenL1MissCallsL2AndPopulatesL1() { // Arrange var fakeL2 = A.Fake(); - var uniqueKey = CacheKey.Create("test", $"test-key-{Guid.NewGuid()}"); // Use unique key to avoid L1 cache pollution from other tests - A.CallTo(() => fakeL2.GetAsync(uniqueKey, A._)) - .Returns("l2-value"); + var uniqueKey = CacheKey.Create( + "test", + $"test-key-{Guid.NewGuid()}" + ); // Use unique key to avoid L1 cache pollution from other tests + A.CallTo(() => fakeL2.GetAsync(uniqueKey, A._)).Returns("l2-value"); var cache = new MultiLayerCache(fakeL2, NullLogger.Instance); @@ -121,8 +125,7 @@ public async Task GetAsyncWhenL1MissCallsL2AndPopulatesL1() // Assert result1.Should().Be("l2-value"); result2.Should().Be("l2-value"); - A.CallTo(() => fakeL2.GetAsync(uniqueKey, A._)) - .MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeL2.GetAsync(uniqueKey, A._)).MustHaveHappenedOnceExactly(); } [Fact] @@ -131,7 +134,10 @@ public async Task SetAsyncWritesToBothL1AndL2() // Arrange var fakeL2 = A.Fake(); var cache = new MultiLayerCache(fakeL2, NullLogger.Instance); - var uniqueKey = CacheKey.Create("test", $"test-key-{Guid.NewGuid()}"); // Use unique key to avoid L1 cache pollution from other tests + var uniqueKey = CacheKey.Create( + "test", + $"test-key-{Guid.NewGuid()}" + ); // Use unique key to avoid L1 cache pollution from other tests // Act await cache.SetAsync(uniqueKey, "value", TimeSpan.FromMinutes(1), TestContext.Current.CancellationToken); @@ -141,8 +147,7 @@ public async Task SetAsyncWritesToBothL1AndL2() // Assert result.Should().Be("value", "L1 should have the value"); - A.CallTo(() => fakeL2.SetAsync(uniqueKey, "value", TimeSpan.FromMinutes(1), A._)) - .MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeL2.SetAsync(uniqueKey, "value", TimeSpan.FromMinutes(1), A._)).MustHaveHappenedOnceExactly(); } [Fact] @@ -150,8 +155,7 @@ public async Task GetAsyncWhenBothCachesMissReturnsNull() { // Arrange var fakeL2 = A.Fake(); - A.CallTo(() => fakeL2.GetAsync(A._, A._)) - .Returns((string?)null); + A.CallTo(() => fakeL2.GetAsync(A._, A._)).Returns((string?)null); var cache = new MultiLayerCache(fakeL2, NullLogger.Instance); var key = CacheKey.Create("test", "missing-key"); @@ -180,13 +184,15 @@ public async Task GetAsyncWhenItemExistsReturnsValue() { ["CacheKey"] = new AttributeValue { S = key.Value }, ["Value"] = new AttributeValue { S = "test-value" }, - ["TTL"] = new AttributeValue { N = DateTimeOffset.UtcNow.AddMinutes(30).ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) } + ["TTL"] = new AttributeValue + { + N = DateTimeOffset.UtcNow.AddMinutes(30).ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) + } }, IsItemSet = true }; - A.CallTo(() => fakeDynamoDb.GetItemAsync(A._, A._)) - .Returns(response); + A.CallTo(() => fakeDynamoDb.GetItemAsync(A._, A._)).Returns(response); var cache = new DynamoDbDistributedCache(fakeDynamoDb, "test-table", NullLogger.Instance); @@ -205,8 +211,7 @@ public async Task GetAsyncWhenItemDoesNotExistReturnsNull() var key = CacheKey.Create("test", "missing-key"); var response = new GetItemResponse { IsItemSet = false }; - A.CallTo(() => fakeDynamoDb.GetItemAsync(A._, A._)) - .Returns(response); + A.CallTo(() => fakeDynamoDb.GetItemAsync(A._, A._)).Returns(response); var cache = new DynamoDbDistributedCache(fakeDynamoDb, "test-table", NullLogger.Instance); @@ -229,14 +234,15 @@ public async Task SetAsyncCallsDynamoDbPutItem() await cache.SetAsync(key, "value", TimeSpan.FromMinutes(30), TestContext.Current.CancellationToken); // Assert - A.CallTo(() => fakeDynamoDb.PutItemAsync( - A.That.Matches(r => - r.TableName == "test-table" && - r.Item["CacheKey"].S == key.Value && - r.Item["Value"].S == "value" - ), - A._ - )).MustHaveHappenedOnceExactly(); + A.CallTo( + () => + fakeDynamoDb.PutItemAsync( + A.That.Matches( + r => r.TableName == "test-table" && r.Item["CacheKey"].S == key.Value && r.Item["Value"].S == "value" + ), + A._ + ) + ).MustHaveHappenedOnceExactly(); } [Fact] @@ -246,8 +252,7 @@ public async Task GetAsyncWhenTableNotFoundReturnsNullGracefully() var fakeDynamoDb = A.Fake(); var key = CacheKey.Create("test", "key"); var exception = new ResourceNotFoundException("Table not found"); - A.CallTo(() => fakeDynamoDb.GetItemAsync(A._, A._)) - .Throws(exception); + A.CallTo(() => fakeDynamoDb.GetItemAsync(A._, A._)).Throws(exception); var cache = new DynamoDbDistributedCache(fakeDynamoDb, "missing-table", NullLogger.Instance); @@ -270,8 +275,7 @@ public async Task GenerateIdTokenAsyncUsesCachedTokenWhenValid() var fakeHandler = A.Fake(); using var httpClient = new HttpClient(fakeHandler); var fakeHttpClientFactory = A.Fake(); - A.CallTo(() => fakeHttpClientFactory.CreateClient(A._)) - .Returns(httpClient); + A.CallTo(() => fakeHttpClientFactory.CreateClient(A._)).Returns(httpClient); var cache = new InMemoryDistributedCache(); diff --git a/tests/Elastic.Documentation.Api.Tests/AskAiGatewayStreamingTests.cs b/tests/Elastic.Documentation.Api.Tests/AskAiGatewayStreamingTests.cs index 96ec8ce3d0..fa3b1042df 100644 --- a/tests/Elastic.Documentation.Api.Tests/AskAiGatewayStreamingTests.cs +++ b/tests/Elastic.Documentation.Api.Tests/AskAiGatewayStreamingTests.cs @@ -23,16 +23,15 @@ namespace Elastic.Documentation.Api.Tests; public class AskAiGatewayStreamingTests { private static IConfiguration CreateTestConfiguration(Dictionary values) => - new ConfigurationBuilder() - .AddInMemoryCollection(values) - .Build(); + new ConfigurationBuilder().AddInMemoryCollection(values).Build(); [Fact] public async Task AgentBuilderGatewayDoesNotDisposeHttpResponsePrematurely() { // Arrange var mockHandler = new MockHttpMessageHandler(); - var sseResponse = """ + var sseResponse = + """ data: {"type":"conversationStart","id":"test","conversation_id":"test"} data: {"type":"messageChunk","id":"m1","content":"Hello"} @@ -85,7 +84,8 @@ public async Task AgentBuilderGatewayAllowsMultipleReadsFromStream() { // Arrange var mockHandler = new MockHttpMessageHandler(); - var sseResponse = """ + var sseResponse = + """ data: {"type":"conversationStart","id":"test","conversation_id":"test"} data: {"type":"messageChunk","id":"m1","content":"A"} @@ -139,7 +139,8 @@ public async Task LlmGatewayDoesNotDisposeHttpResponsePrematurely() { // Arrange var mockHandler = new MockHttpMessageHandler(); - var sseResponse = """ + var sseResponse = + """ data: {"type":"conversationStart","id":"test","conversation_id":"test"} data: {"type":"reasoning","content":"thinking..."} @@ -155,8 +156,9 @@ public async Task LlmGatewayDoesNotDisposeHttpResponsePrematurely() using var httpClient = new HttpClient(mockHandler); var mockTokenProvider = A.Fake(); - A.CallTo(() => mockTokenProvider.GenerateIdTokenAsync(A._, A._, A._)) - .Returns(Task.FromResult("mock-gcp-token")); + A.CallTo(() => mockTokenProvider.GenerateIdTokenAsync(A._, A._, A._)).Returns( + Task.FromResult("mock-gcp-token") + ); var options = new LlmGatewayOptions(CreateTestConfiguration(new Dictionary { @@ -197,7 +199,8 @@ public async Task LlmGatewayGatewayAllowsMultipleReadsFromStream() { // Arrange var mockHandler = new MockHttpMessageHandler(); - var sseResponse = """ + var sseResponse = + """ data: {"type":"conversationStart","id":"test","conversation_id":"test"} data: {"type":"messageChunk","id":"m","content":"1"} @@ -215,8 +218,9 @@ public async Task LlmGatewayGatewayAllowsMultipleReadsFromStream() using var httpClient = new HttpClient(mockHandler); var mockTokenProvider = A.Fake(); - A.CallTo(() => mockTokenProvider.GenerateIdTokenAsync(A._, A._, A._)) - .Returns(Task.FromResult("mock-token")); + A.CallTo(() => mockTokenProvider.GenerateIdTokenAsync(A._, A._, A._)).Returns( + Task.FromResult("mock-token") + ); var options = new LlmGatewayOptions(CreateTestConfiguration(new Dictionary { @@ -307,10 +311,8 @@ public void SetResponse(string content, string contentType) }; } - public void SetErrorResponse(HttpStatusCode statusCode, string errorMessage) => _responseToReturn = new HttpResponseMessage(statusCode) - { - Content = new StringContent(errorMessage) - }; + public void SetErrorResponse(HttpStatusCode statusCode, string errorMessage) => + _responseToReturn = new HttpResponseMessage(statusCode) { Content = new StringContent(errorMessage) }; protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { diff --git a/tests/Elastic.Documentation.Api.Tests/EuidEnrichmentTests.cs b/tests/Elastic.Documentation.Api.Tests/EuidEnrichmentTests.cs index 6192e81620..5837fe8b01 100644 --- a/tests/Elastic.Documentation.Api.Tests/EuidEnrichmentTests.cs +++ b/tests/Elastic.Documentation.Api.Tests/EuidEnrichmentTests.cs @@ -52,26 +52,29 @@ public async Task AskAiEndpointPropagatatesEuidToAllSpansAndLogs() { // Mock IAskAiService to avoid external AI service calls var mockAskAiGateway = A.Fake(); - A.CallTo(() => mockAskAiGateway.AskAi(A._, A._)) - .ReturnsLazily(() => - { - var stream = new MemoryStream(Encoding.UTF8.GetBytes("data: test\n\n")); - mockStreams.Add(stream); - return Task.FromResult(new AskAiGatewayResponse(stream, GeneratedConversationId: Guid.NewGuid())); - }); + A.CallTo(() => mockAskAiGateway.AskAi(A._, A._)).ReturnsLazily(() => + { + var stream = new MemoryStream(Encoding.UTF8.GetBytes("data: test\n\n")); + mockStreams.Add(stream); + return Task.FromResult(new AskAiGatewayResponse(stream, GeneratedConversationId: Guid.NewGuid())); + }); services.AddSingleton(mockAskAiGateway); // Mock IStreamTransformer var mockTransformer = A.Fake(); A.CallTo(() => mockTransformer.AgentProvider).Returns("test-provider"); A.CallTo(() => mockTransformer.AgentId).Returns("test-agent"); - A.CallTo(() => mockTransformer.TransformAsync(A._, A._, A._, A._)) - .ReturnsLazily((Stream s, Guid? _, Activity? activity, Cancel _) => - { - // Dispose the activity if provided (simulating what the real transformer does) - activity?.Dispose(); - return Task.FromResult(s); - }); + A.CallTo(() => mockTransformer.TransformAsync(A._, A._, A._, A._)).ReturnsLazily(( + Stream s, + Guid? _, + Activity? activity, + Cancel _ + ) => + { + // Dispose the activity if provided (simulating what the real transformer does) + activity?.Dispose(); + return Task.FromResult(s); + }); services.AddSingleton(mockTransformer); }); @@ -81,12 +84,13 @@ public async Task AskAiEndpointPropagatatesEuidToAllSpansAndLogs() // Act - Make request to /ask-ai/stream with euid cookie using var request = new HttpRequestMessage(HttpMethod.Post, "/docs/_api/v1/ask-ai/stream"); request.Headers.Add("Cookie", $"euid={expectedEuid}"); - request.Content = new StringContent( - /*lang=json,strict*/ - """{"message":"test question","conversationId":null}""", - Encoding.UTF8, - "application/json" - ); + request.Content = + new StringContent( + /*lang=json,strict*/ + """{"message":"test question","conversationId":null}""", + Encoding.UTF8, + "application/json" + ); using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); @@ -114,8 +118,9 @@ public async Task AskAiEndpointPropagatatesEuidToAllSpansAndLogs() logRecords.Should().NotBeEmpty("Should have captured log records"); // Find a log entry from the AskAI endpoint handler - var askAiLogRecord = logRecords.FirstOrDefault(r => - r.FormattedMessage?.Contains("Starting AskAI", StringComparison.OrdinalIgnoreCase) == true); + var askAiLogRecord = logRecords.FirstOrDefault( + r => r.FormattedMessage?.Contains("Starting AskAI", StringComparison.OrdinalIgnoreCase) == true + ); askAiLogRecord.Should().NotBeNull("Should have logged from AskAI endpoint handler"); // Verify euid is present in OTEL log attributes (mirrors production exporter behavior) diff --git a/tests/Elastic.Documentation.Api.Tests/Fixtures/ApiWebApplicationFactory.cs b/tests/Elastic.Documentation.Api.Tests/Fixtures/ApiWebApplicationFactory.cs index d7163bb914..62b3d3eeaa 100644 --- a/tests/Elastic.Documentation.Api.Tests/Fixtures/ApiWebApplicationFactory.cs +++ b/tests/Elastic.Documentation.Api.Tests/Fixtures/ApiWebApplicationFactory.cs @@ -40,9 +40,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory private readonly string? _otlpEndpoint; - public ApiWebApplicationFactory() : this(null, null) - { - } + public ApiWebApplicationFactory() : this(null, null) { } internal ApiWebApplicationFactory(Action? configureServices, string? otlpEndpoint = null) { @@ -57,7 +55,10 @@ internal ApiWebApplicationFactory(Action? configureServices, /// Action to configure service replacements /// Optional OTLP endpoint to enable the OTLP proxy routes /// New factory instance with replaced services - public static ApiWebApplicationFactory WithMockedServices(Action serviceReplacements, string? otlpEndpoint = null) + public static ApiWebApplicationFactory WithMockedServices( + Action serviceReplacements, + string? otlpEndpoint = null + ) { var builder = new ServiceReplacementBuilder(); serviceReplacements(builder); @@ -70,8 +71,8 @@ public static ApiWebApplicationFactory WithMockedServices(ActionAction to configure services directly /// Optional OTLP endpoint to enable the OTLP proxy routes /// New factory instance with custom service configuration - public static ApiWebApplicationFactory WithMockedServices(Action configureServices, string? otlpEndpoint = null) - => new(configureServices, otlpEndpoint); + public static ApiWebApplicationFactory WithMockedServices(Action configureServices, string? otlpEndpoint = null) => + new(configureServices, otlpEndpoint); protected override void ConfigureWebHost(IWebHostBuilder builder) { @@ -88,23 +89,23 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) // Configure OpenTelemetry with in-memory exporters for all tests // Each factory instance has its own ExportedActivities and ExportedLogRecords lists var otelBuilder = services.AddOpenTelemetry(); - _ = otelBuilder.WithTracing(tracing => - { - _ = tracing - .AddDocsApiTracing() // Reuses production configuration - .AddInMemoryExporter(ExportedActivities); - }); + _ = + otelBuilder.WithTracing(tracing => + { + _ = + tracing.AddDocsApiTracing() // Reuses production configuration + .AddInMemoryExporter(ExportedActivities); + }); services.AddElasticDocumentationLogging(LogLevel.Information); - _ = otelBuilder.WithLogging(logging => - { - _ = logging - .AddInMemoryExporter(ExportedLogRecords); - }); + _ = + otelBuilder.WithLogging(logging => + { + _ = logging.AddInMemoryExporter(ExportedLogRecords); + }); // Mock IParameterProvider to avoid AWS dependencies in all tests var mockParameterProvider = A.Fake(); - A.CallTo(() => mockParameterProvider.GetParam(A._, A._, A._)) - .Returns(Task.FromResult("mock-value")); + A.CallTo(() => mockParameterProvider.GetParam(A._, A._, A._)).Returns(Task.FromResult("mock-value")); _ = services.AddSingleton(mockParameterProvider); // Apply test-specific service replacements (if any) @@ -172,11 +173,12 @@ public ServiceReplacementBuilder ReplaceSingleton(TService instance) w /// /// Builds the final service configuration action. /// - internal Action Build() => services => - { - foreach (var replacement in _replacements) + internal Action Build() => + services => { - replacement(services); - } - }; + foreach (var replacement in _replacements) + { + replacement(services); + } + }; } diff --git a/tests/Elastic.Documentation.Build.Tests/AssemblerBuildServiceTests.cs b/tests/Elastic.Documentation.Build.Tests/AssemblerBuildServiceTests.cs index c487ad6b58..c55abb4ea6 100644 --- a/tests/Elastic.Documentation.Build.Tests/AssemblerBuildServiceTests.cs +++ b/tests/Elastic.Documentation.Build.Tests/AssemblerBuildServiceTests.cs @@ -122,10 +122,14 @@ public void Constructor_AcceptsIEnvironmentVariables() } [Theory] - [InlineData(true, true)] // CI + assumeBuild=true -> should throw - [InlineData(true, false)] // CI + assumeBuild=false -> should not throw - [InlineData(false, true)] // Local + assumeBuild=true -> should not throw + [InlineData(true, true)] // CI + assumeBuild=true -> should throw + + [InlineData(true, false)] // CI + assumeBuild=false -> should not throw + + [InlineData(false, true)] // Local + assumeBuild=true -> should not throw + [InlineData(false, false)] // Local + assumeBuild=false -> should not throw + public void AssumeBuildValidation_FollowsTruthTable(bool isCI, bool assumeBuild) { // This test validates the truth table behavior for assumeBuild validation. diff --git a/tests/Elastic.Documentation.Build.Tests/AssemblerOpenApiBuildStepIntegrationTests.cs b/tests/Elastic.Documentation.Build.Tests/AssemblerOpenApiBuildStepIntegrationTests.cs index 0b420670a7..6594035986 100644 --- a/tests/Elastic.Documentation.Build.Tests/AssemblerOpenApiBuildStepIntegrationTests.cs +++ b/tests/Elastic.Documentation.Build.Tests/AssemblerOpenApiBuildStepIntegrationTests.cs @@ -44,7 +44,8 @@ public async Task BuildAsync_GeneratesApiPagesWhenFlagEnabledAndDocsetPresent() var fileSystem = new FileSystem(); var collector = new DiagnosticsCollector([]); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); - var assemblyConfig = AssemblyConfiguration.Deserialize(""" + var assemblyConfig = AssemblyConfiguration.Deserialize( + """ environments: staging: uri: https://staging-website.elastic.co @@ -55,11 +56,14 @@ public async Task BuildAsync_GeneratesApiPagesWhenFlagEnabledAndDocsetPresent() narrative: checkout_strategy: full references: {} - """); + """ + ); using var scopedWorkspace = new ScopedTempDirectory(fileSystem, "assembler-openapi-integration"); var workspaceRoot = scopedWorkspace.Directory; var docsetPath = fileSystem.Path.Join(workspaceRoot.FullName, "docset.yml"); - fileSystem.File.WriteAllText(docsetPath, """ + fileSystem.File.WriteAllText( + docsetPath, + """ project: test toc: - file: index.md @@ -68,7 +72,8 @@ public async Task BuildAsync_GeneratesApiPagesWhenFlagEnabledAndDocsetPresent() - spec: elasticsearch.json product: elasticsearch repository: elastic/elasticsearch-specification - """); + """ + ); fileSystem.File.WriteAllText(fileSystem.Path.Join(workspaceRoot.FullName, "index.md"), "# Test\n"); InitializeGitCheckout(fileSystem, workspaceRoot.FullName); var outputDirectory = fileSystem.Path.Join(workspaceRoot.FullName, "output"); @@ -80,7 +85,8 @@ public async Task BuildAsync_GeneratesApiPagesWhenFlagEnabledAndDocsetPresent() collector, assembleFs, workspaceRoot.FullName, - outputDirectory); + outputDirectory + ); var checkout = new Checkout { Repository = new Repository { Name = "docs-content", Origin = "elastic/docs-content" }, @@ -94,11 +100,12 @@ public async Task BuildAsync_GeneratesApiPagesWhenFlagEnabledAndDocsetPresent() NoopCrossLinkResolver.Instance, new ReleaseNotesResolver(), configurationContext, - ExportOptions.Default); + ExportOptions.Default + ); var assembleSources = AssembleSources.ForTests( context, - new Dictionary { [checkout.Repository.Name] = documentationSet } - .ToFrozenDictionary()); + new Dictionary { [checkout.Repository.Name] = documentationSet }.ToFrozenDictionary() + ); await documentationSet.DocumentationSet.ResolveDirectoryTree(TestContext.Current.CancellationToken); @@ -107,11 +114,11 @@ await AssemblerOpenApiBuildStep.BuildAsync( NullLoggerFactory.Instance, context, assembleSources, - TestContext.Current.CancellationToken); + TestContext.Current.CancellationToken + ); stopwatch.Stop(); - TestContext.Current.TestOutputHelper?.WriteLine( - $"OpenAPI assembler step completed in {stopwatch.ElapsedMilliseconds} ms"); + TestContext.Current.TestOutputHelper?.WriteLine($"OpenAPI assembler step completed in {stopwatch.ElapsedMilliseconds} ms"); collector.Errors.Should().Be(0); @@ -119,13 +126,14 @@ await AssemblerOpenApiBuildStep.BuildAsync( fileSystem.Directory.Exists(apiRoot).Should().BeTrue(); var elasticsearchLanding = fileSystem.Path.Join(apiRoot, "doc", "elasticsearch", "index.html"); - fileSystem.File.Exists(elasticsearchLanding).Should().BeTrue( - "staging assembler builds should emit the unversioned elasticsearch API landing page"); + fileSystem.File + .Exists(elasticsearchLanding) + .Should() + .BeTrue("staging assembler builds should emit the unversioned elasticsearch API landing page"); var versionedLanding = fileSystem.Directory .EnumerateDirectories(fileSystem.Path.Join(apiRoot, "doc", "elasticsearch")) .FirstOrDefault(path => fileSystem.Path.GetFileName(path).StartsWith('v')); - versionedLanding.Should().NotBeNull( - "versioned products should emit at least one /vN/ tree under /docs/api/doc/elasticsearch/"); + versionedLanding.Should().NotBeNull("versioned products should emit at least one /vN/ tree under /docs/api/doc/elasticsearch/"); } } diff --git a/tests/Elastic.Documentation.Build.Tests/AssemblerOpenApiBuildStepTests.cs b/tests/Elastic.Documentation.Build.Tests/AssemblerOpenApiBuildStepTests.cs index f3c5d250ec..be8610c006 100644 --- a/tests/Elastic.Documentation.Build.Tests/AssemblerOpenApiBuildStepTests.cs +++ b/tests/Elastic.Documentation.Build.Tests/AssemblerOpenApiBuildStepTests.cs @@ -25,7 +25,8 @@ public class AssemblerOpenApiBuildStepTests : IDisposable { private readonly List _tempDirectories = []; - private static readonly string MinimalAssemblerYaml = """ + private static readonly string MinimalAssemblerYaml = + """ environments: prod: uri: https://www.elastic.co @@ -59,17 +60,21 @@ public async Task BuildAsync_SkipsWhenFeatureFlagDisabled() collector, assembleFs, tempDirectory.FullName, - outputDirectory); + outputDirectory + ); var assembleSources = AssembleSources.ForTests(context, FrozenDictionary.Empty); await AssemblerOpenApiBuildStep.BuildAsync( NullLoggerFactory.Instance, context, assembleSources, - TestContext.Current.CancellationToken); + TestContext.Current.CancellationToken + ); - fileSystem.Directory.Exists(fileSystem.Path.Join(outputDirectory, "docs", "api")) - .Should().BeFalse("OpenAPI generation must not run when the feature flag is disabled"); + fileSystem.Directory + .Exists(fileSystem.Path.Join(outputDirectory, "docs", "api")) + .Should() + .BeFalse("OpenAPI generation must not run when the feature flag is disabled"); } [Fact] @@ -89,17 +94,21 @@ public async Task BuildAsync_SkipsWhenNoApiDeclarationsAndFlagEnabled() collector, assembleFs, tempDirectory.FullName, - outputDirectory); + outputDirectory + ); var assembleSources = AssembleSources.ForTests(context, FrozenDictionary.Empty); await AssemblerOpenApiBuildStep.BuildAsync( NullLoggerFactory.Instance, context, assembleSources, - TestContext.Current.CancellationToken); + TestContext.Current.CancellationToken + ); - fileSystem.Directory.Exists(fileSystem.Path.Join(outputDirectory, "docs", "api")) - .Should().BeFalse("OpenAPI generation must not run without API declarations"); + fileSystem.Directory + .Exists(fileSystem.Path.Join(outputDirectory, "docs", "api")) + .Should() + .BeFalse("OpenAPI generation must not run without API declarations"); } [Fact] @@ -143,7 +152,6 @@ public void Dispose() GC.SuppressFinalize(this); } - private static void InitializeGitCheckout(IFileSystem fileSystem, string checkoutRoot) { var gitDir = fileSystem.Path.Join(checkoutRoot, ".git"); @@ -158,10 +166,7 @@ private IDirectoryInfo CreateTempDirectory(IFileSystem fileSystem) return tempDirectory.Directory; } - private AssemblerDocumentationSet CreateDocumentationSet( - string repositoryName, - string? apiKey, - DiagnosticsCollector collector) + private AssemblerDocumentationSet CreateDocumentationSet(string repositoryName, string? apiKey, DiagnosticsCollector collector) { var fileSystem = new FileSystem(); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); @@ -199,7 +204,8 @@ private AssemblerDocumentationSet CreateDocumentationSet( collector, assembleFs, checkoutRoot.FullName, - outputDirectory); + outputDirectory + ); var checkout = new Checkout { Repository = new Repository { Name = repositoryName, Origin = $"elastic/{repositoryName}" }, @@ -213,6 +219,7 @@ private AssemblerDocumentationSet CreateDocumentationSet( NoopCrossLinkResolver.Instance, new ReleaseNotesResolver(), configurationContext, - ExportOptions.Default); + ExportOptions.Default + ); } } diff --git a/tests/Elastic.Documentation.Build.Tests/ExternalCommandExecutorRetryTests.cs b/tests/Elastic.Documentation.Build.Tests/ExternalCommandExecutorRetryTests.cs index c09cc9c69f..63203178a5 100644 --- a/tests/Elastic.Documentation.Build.Tests/ExternalCommandExecutorRetryTests.cs +++ b/tests/Elastic.Documentation.Build.Tests/ExternalCommandExecutorRetryTests.cs @@ -52,11 +52,9 @@ public void ExecInWithRetry_ExhaustsAllAttempts_EmitsExactlyOneError() succeeded.Should().BeFalse(); executor.CallCount.Should().Be(5); executor.Diagnostics.Errors.Should().Be(1); - executor.RecordedDelays.Should().Equal( - TimeSpan.FromSeconds(1), - TimeSpan.FromSeconds(2), - TimeSpan.FromSeconds(4), - TimeSpan.FromSeconds(8)); + executor.RecordedDelays + .Should() + .Equal(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(8)); } [Fact] @@ -92,8 +90,11 @@ private static RetryTestCommandExecutor CreateExecutor(params int[] exitCodes) return new RetryTestCommandExecutor(new DiagnosticsCollector([]), workingDirectory, exitCodes); } - private sealed class RetryTestCommandExecutor(IDiagnosticsCollector collector, IDirectoryInfo workingDirectory, int[] exitCodes) - : ExternalCommandExecutor(collector, workingDirectory) + private sealed class RetryTestCommandExecutor( + IDiagnosticsCollector collector, + IDirectoryInfo workingDirectory, + int[] exitCodes + ) : ExternalCommandExecutor(collector, workingDirectory) { public int CallCount { get; private set; } @@ -106,7 +107,9 @@ private sealed class RetryTestCommandExecutor(IDiagnosticsCollector collector, I protected override int ExecInCore(Dictionary environmentVars, string binary, params string[] args) { if (CallCount >= exitCodes.Length) - throw new InvalidOperationException($"Unexpected invocation {CallCount + 1}, only {exitCodes.Length} exit codes were scripted"); + throw new InvalidOperationException( + $"Unexpected invocation {CallCount + 1}, only {exitCodes.Length} exit codes were scripted" + ); return exitCodes[CallCount++]; } diff --git a/tests/Elastic.Documentation.Build.Tests/FeatureFlagsTests.cs b/tests/Elastic.Documentation.Build.Tests/FeatureFlagsTests.cs index eb034e3a48..495132f843 100644 --- a/tests/Elastic.Documentation.Build.Tests/FeatureFlagsTests.cs +++ b/tests/Elastic.Documentation.Build.Tests/FeatureFlagsTests.cs @@ -36,10 +36,7 @@ public void AssemblerApiExplorerEnabled_EnvironmentVariableOverridesYaml() try { Environment.SetEnvironmentVariable("FEATURE_ASSEMBLER_API_EXPLORER", "false"); - var flags = new FeatureFlags(new Dictionary - { - ["assembler-api-explorer"] = true - }); + var flags = new FeatureFlags(new Dictionary { ["assembler-api-explorer"] = true }); flags.AssemblerApiExplorerEnabled.Should().BeFalse(); } @@ -52,11 +49,12 @@ public void AssemblerApiExplorerEnabled_EnvironmentVariableOverridesYaml() [Fact] public void StagingEnvironment_EnablesAssemblerApiExplorer() { - var config = AssemblyConfiguration.Create(new ConfigurationFileProvider(new TestLoggerFactory(null), new ConfigurationFileSystem())); + var config = AssemblyConfiguration.Create( + new ConfigurationFileProvider(new TestLoggerFactory(null), new ConfigurationFileSystem()) + ); var staging = config.Environments["staging"]; - staging.FeatureFlags.Should().ContainKey("ASSEMBLER_API_EXPLORER") - .WhoseValue.Should().BeTrue(); + staging.FeatureFlags.Should().ContainKey("ASSEMBLER_API_EXPLORER").WhoseValue.Should().BeTrue(); var features = new FeatureFlags([]); foreach (var (key, value) in staging.FeatureFlags) @@ -67,7 +65,9 @@ public void StagingEnvironment_EnablesAssemblerApiExplorer() [Fact] public void ProdEnvironment_DoesNotEnableAssemblerApiExplorer() { - var config = AssemblyConfiguration.Create(new ConfigurationFileProvider(new TestLoggerFactory(null), new ConfigurationFileSystem())); + var config = AssemblyConfiguration.Create( + new ConfigurationFileProvider(new TestLoggerFactory(null), new ConfigurationFileSystem()) + ); var prod = config.Environments["prod"]; prod.FeatureFlags.Should().NotContainKey("ASSEMBLER_API_EXPLORER"); diff --git a/tests/Elastic.Documentation.Build.Tests/IsolatedBuildServiceTests.cs b/tests/Elastic.Documentation.Build.Tests/IsolatedBuildServiceTests.cs index 824c6cd143..449e39dbbc 100644 --- a/tests/Elastic.Documentation.Build.Tests/IsolatedBuildServiceTests.cs +++ b/tests/Elastic.Documentation.Build.Tests/IsolatedBuildServiceTests.cs @@ -116,10 +116,14 @@ public void IsRunningOnCI_WhenGitHubActionsNotSet_ReturnsFalse() } [Theory] - [InlineData(true, true)] // CI + force=true -> force should be true - [InlineData(true, false)] // CI + force=false -> force should be true (CI override) - [InlineData(false, true)] // Local + force=true -> force should be true + [InlineData(true, true)] // CI + force=true -> force should be true + + [InlineData(true, false)] // CI + force=false -> force should be true (CI override) + + [InlineData(false, true)] // Local + force=true -> force should be true + [InlineData(false, false)] // Local + force=false -> force should be false + public void Build_CIOverridesForceParameter_AsExpected(bool isCI, bool forceParam) { // This test validates the truth table rows for the 'Effective Force' column. diff --git a/tests/Elastic.Documentation.Build.Tests/MockEnvironmentVariables.cs b/tests/Elastic.Documentation.Build.Tests/MockEnvironmentVariables.cs index 284b950c4a..416c69eda6 100644 --- a/tests/Elastic.Documentation.Build.Tests/MockEnvironmentVariables.cs +++ b/tests/Elastic.Documentation.Build.Tests/MockEnvironmentVariables.cs @@ -43,12 +43,10 @@ public void SetCI(bool isCI) } /// - public string? GetEnvironmentVariable(string name) => - _variables.TryGetValue(name, out var value) ? value : null; + public string? GetEnvironmentVariable(string name) => _variables.TryGetValue(name, out var value) ? value : null; /// - public bool IsRunningOnCI => - !string.IsNullOrEmpty(GetEnvironmentVariable("GITHUB_ACTIONS")); + public bool IsRunningOnCI => !string.IsNullOrEmpty(GetEnvironmentVariable("GITHUB_ACTIONS")); /// /// Creates a mock environment that simulates running on CI. diff --git a/tests/Elastic.Documentation.Build.Tests/RedirectKvsDiffTests.cs b/tests/Elastic.Documentation.Build.Tests/RedirectKvsDiffTests.cs index ce8c1efbd6..e1bb2f58b0 100644 --- a/tests/Elastic.Documentation.Build.Tests/RedirectKvsDiffTests.cs +++ b/tests/Elastic.Documentation.Build.Tests/RedirectKvsDiffTests.cs @@ -42,15 +42,8 @@ public void ComputeBatchUpdates_KeyRemovedFromSourced_AppearsInToDelete() // to the KVS is dropped from redirects.json. The diff MUST flag it for deletion. const string staleKey = "/docs/deploy-manage/deploy/elastic-cloud/azure-native-isv-service"; - var sourcedRedirects = new Dictionary - { - ["/docs/some-other-page"] = "/docs/some-other-page-new" - }; - var existingRedirects = new HashSet - { - staleKey, - "/docs/some-other-page" - }; + var sourcedRedirects = new Dictionary { ["/docs/some-other-page"] = "/docs/some-other-page-new" }; + var existingRedirects = new HashSet { staleKey, "/docs/some-other-page" }; var (toPut, toDelete) = RedirectKvsDiff.ComputeBatchUpdates(sourcedRedirects, existingRedirects); @@ -61,19 +54,12 @@ public void ComputeBatchUpdates_KeyRemovedFromSourced_AppearsInToDelete() [Fact] public void ComputeBatchUpdates_KeyOnlyInSourced_IsPutAndNotDeleted() { - var sourcedRedirects = new Dictionary - { - ["/docs/new-page"] = "/docs/new-page-target" - }; + var sourcedRedirects = new Dictionary { ["/docs/new-page"] = "/docs/new-page-target" }; var existingRedirects = new HashSet(); var (toPut, toDelete) = RedirectKvsDiff.ComputeBatchUpdates(sourcedRedirects, existingRedirects); - toPut.Should().ContainSingle().Which.Should().BeEquivalentTo(new - { - Key = "/docs/new-page", - Value = "/docs/new-page-target" - }); + toPut.Should().ContainSingle().Which.Should().BeEquivalentTo(new { Key = "/docs/new-page", Value = "/docs/new-page-target" }); toDelete.Should().BeEmpty(); } @@ -83,10 +69,7 @@ public void ComputeBatchUpdates_KeyInBoth_IsPutAndNotDeleted() // The current implementation re-puts every sourced entry (even unchanged values). // This pins that behaviour explicitly so a future optimisation that skips // no-op puts has to update both the code and this test. - var sourcedRedirects = new Dictionary - { - ["/docs/shared-key"] = "/docs/target" - }; + var sourcedRedirects = new Dictionary { ["/docs/shared-key"] = "/docs/target" }; var existingRedirects = new HashSet { "/docs/shared-key" }; var (toPut, toDelete) = RedirectKvsDiff.ComputeBatchUpdates(sourcedRedirects, existingRedirects); @@ -98,9 +81,7 @@ public void ComputeBatchUpdates_KeyInBoth_IsPutAndNotDeleted() [Fact] public void ComputeBatchUpdates_BothEmpty_ProducesEmptyBatches() { - var (toPut, toDelete) = RedirectKvsDiff.ComputeBatchUpdates( - new Dictionary(), - new HashSet()); + var (toPut, toDelete) = RedirectKvsDiff.ComputeBatchUpdates(new Dictionary(), new HashSet()); toPut.Should().BeEmpty(); toDelete.Should().BeEmpty(); @@ -112,12 +93,7 @@ public void ComputeBatchUpdates_OnlyExisting_AllAreDeleted() // Mirrors a "remove every redirect" intent. The helper itself does not refuse // to compute this; the wipe guard (WouldWipeAllExisting) lives one layer up. var sourcedRedirects = new Dictionary(); - var existingRedirects = new HashSet - { - "/docs/a", - "/docs/b", - "/docs/c" - }; + var existingRedirects = new HashSet { "/docs/a", "/docs/b", "/docs/c" }; var (toPut, toDelete) = RedirectKvsDiff.ComputeBatchUpdates(sourcedRedirects, existingRedirects); @@ -138,25 +114,22 @@ public void ComputeBatchUpdates_AzureIsvRegression_ScenarioEndToEnd() ["/docs/some/other/legitimate-old"] = "/docs/some/other/legitimate-new", ["/docs/another/page"] = "/docs/another/page-renamed" }; - var existingRedirects = new HashSet - { - stalePath, - "/docs/some/other/legitimate-old", - "/docs/historical-entry-still-valid" - }; + var existingRedirects = new HashSet { stalePath, "/docs/some/other/legitimate-old", "/docs/historical-entry-still-valid" }; var (toPut, toDelete) = RedirectKvsDiff.ComputeBatchUpdates(sourcedRedirects, existingRedirects); - toDelete.Select(d => d.Key).Should().Contain(stalePath, - because: "the stale Azure ISV redirect must be removed from the KVS once it is dropped from redirects.yml"); - toDelete.Select(d => d.Key).Should().Contain("/docs/historical-entry-still-valid", - because: "any KVS key absent from the sourced redirects is stale by definition"); - toDelete.Select(d => d.Key).Should().NotContain("/docs/another/page", - because: "brand-new sourced entries belong in the PUT batch, not the DELETE batch"); + toDelete.Select(d => d.Key) + .Should() + .Contain(stalePath, because: "the stale Azure ISV redirect must be removed from the KVS once it is dropped from redirects.yml"); + toDelete.Select(d => d.Key) + .Should() + .Contain("/docs/historical-entry-still-valid", because: "any KVS key absent from the sourced redirects is stale by definition"); + toDelete.Select(d => d.Key) + .Should() + .NotContain("/docs/another/page", because: "brand-new sourced entries belong in the PUT batch, not the DELETE batch"); toPut.Select(p => p.Key).Should().Contain("/docs/another/page"); - toPut.Should().NotContain(p => p.Key == stalePath, - because: "we never PUT a value for a key that the sourced file dropped"); + toPut.Should().NotContain(p => p.Key == stalePath, because: "we never PUT a value for a key that the sourced file dropped"); // Sanity: each toDelete key must come from the live KVS, never from the sourced set. foreach (var del in toDelete) diff --git a/tests/Elastic.Documentation.Build.Tests/SitemapTests.cs b/tests/Elastic.Documentation.Build.Tests/SitemapTests.cs index 6734381784..f2cb1d8fcc 100644 --- a/tests/Elastic.Documentation.Build.Tests/SitemapTests.cs +++ b/tests/Elastic.Documentation.Build.Tests/SitemapTests.cs @@ -54,10 +54,7 @@ public void Generate_CreatesOutputDirectory_WhenItDoesNotExist() var fs = new MockFileSystem(); var outputDir = fs.DirectoryInfo.New("/nonexistent/output"); - var entries = new Dictionary - { - ["/docs/test"] = DateTimeOffset.UtcNow, - }; + var entries = new Dictionary { ["/docs/test"] = DateTimeOffset.UtcNow, }; // Act SitemapBuilder.Generate(entries, fs, outputDir); @@ -75,12 +72,7 @@ public void Generate_OrdersUrlsAlphabetically() fs.Directory.CreateDirectory("/output"); var now = DateTimeOffset.UtcNow; - var entries = new Dictionary - { - ["/docs/z-last"] = now, - ["/docs/a-first"] = now, - ["/docs/m-middle"] = now, - }; + var entries = new Dictionary { ["/docs/z-last"] = now, ["/docs/a-first"] = now, ["/docs/m-middle"] = now, }; // Act SitemapBuilder.Generate(entries, fs, outputDir); @@ -101,11 +93,7 @@ public void Generate_ReturnsEntryCountAndFileSize() var fs = new MockFileSystem(); var outputDir = fs.DirectoryInfo.New("/output"); var now = DateTimeOffset.UtcNow; - var entries = new Dictionary - { - ["/docs/page-1"] = now, - ["/docs/page-2"] = now, - }; + var entries = new Dictionary { ["/docs/page-1"] = now, ["/docs/page-2"] = now, }; // Act var result = SitemapBuilder.Generate(entries, fs, outputDir); @@ -122,15 +110,13 @@ public void Generate_ThrowsWhenEntryCountExceedsLimit() var fs = new MockFileSystem(); var outputDir = fs.DirectoryInfo.New("/output"); var now = DateTimeOffset.UtcNow; - var entries = Enumerable.Range(0, SitemapBuilder.MaxEntries + 1) - .ToDictionary(i => $"/docs/page-{i}", _ => now); + var entries = Enumerable.Range(0, SitemapBuilder.MaxEntries + 1).ToDictionary(i => $"/docs/page-{i}", _ => now); // Act var act = () => SitemapBuilder.Generate(entries, fs, outputDir); // Assert - act.Should().Throw() - .WithMessage("*exceeds the sitemap protocol limit*"); + act.Should().Throw().WithMessage("*exceeds the sitemap protocol limit*"); } [Fact] @@ -140,8 +126,7 @@ public void Generate_DoesNotThrowAtExactLimit() var fs = new MockFileSystem(); var outputDir = fs.DirectoryInfo.New("/output"); var now = DateTimeOffset.UtcNow; - var entries = Enumerable.Range(0, SitemapBuilder.MaxEntries) - .ToDictionary(i => $"/docs/page-{i}", _ => now); + var entries = Enumerable.Range(0, SitemapBuilder.MaxEntries).ToDictionary(i => $"/docs/page-{i}", _ => now); // Act var act = () => SitemapBuilder.Generate(entries, fs, outputDir); diff --git a/tests/Elastic.Documentation.Build.Tests/SymlinkValidationTests.cs b/tests/Elastic.Documentation.Build.Tests/SymlinkValidationTests.cs index 87bc8daec9..7ee848fe10 100644 --- a/tests/Elastic.Documentation.Build.Tests/SymlinkValidationTests.cs +++ b/tests/Elastic.Documentation.Build.Tests/SymlinkValidationTests.cs @@ -89,10 +89,8 @@ public void SymlinkValidator_ThrowsSecurityException_ForSymlinks() { // Note: MockFileSystem doesn't fully support symlinks, so we test the validator logic directly // In a real environment, the IFileInfo.LinkTarget would be set for symlinks - // The validator checks: if (file.LinkTarget != null) throw SecurityException // This test documents the expected behavior - // The actual symlink detection relies on IFileInfo.LinkTarget property // which MockFileSystem may not fully support. Integration tests with // real filesystem would be needed for full coverage. @@ -104,12 +102,7 @@ public void SymlinkValidator_SecurityMessage_DescribesRisk() // Document that the security exception message explains the risk // This helps developers understand why symlinks are rejected - var expectedMessageContains = new[] - { - "symlink", - "not allowed", - "security" - }; + var expectedMessageContains = new[] { "symlink", "not allowed", "security" }; // The actual exception message format: // "Control file '{file.FullName}' is a symlink, which is not allowed for security reasons. @@ -137,7 +130,8 @@ public void ControlFiles_AreProtectedBySymlinkValidation(string fileName) var protectedFiles = new[] { "docset.yml", - "_docset.yml", // Alternative name with underscore prefix + "_docset.yml", // Alternative name with underscore prefix + "toc.yml", "redirects.yml", "_redirects.yml" // Alternative name with underscore prefix diff --git a/tests/Elastic.Documentation.Build.Tests/TestHelpers.cs b/tests/Elastic.Documentation.Build.Tests/TestHelpers.cs index f13aed5c71..c701954fe8 100644 --- a/tests/Elastic.Documentation.Build.Tests/TestHelpers.cs +++ b/tests/Elastic.Documentation.Build.Tests/TestHelpers.cs @@ -27,14 +27,16 @@ public static class TestHelpers public static IConfigurationContext CreateConfigurationContext( IFileSystem fileSystem, VersionsConfiguration? versionsConfiguration = null, - ProductsConfiguration? productsConfiguration = null) + ProductsConfiguration? productsConfiguration = null + ) { versionsConfiguration ??= new VersionsConfiguration { VersioningSystems = new Dictionary { { - VersioningSystemId.Stack, new VersioningSystem + VersioningSystemId.Stack, + new VersioningSystem { Id = VersioningSystemId.Stack, Current = new SemVersion(8, 0, 0), @@ -49,7 +51,8 @@ public static IConfigurationContext CreateConfigurationContext( var products = new Dictionary { { - "elasticsearch", new Product + "elasticsearch", + new Product { Id = "elasticsearch", DisplayName = "Elasticsearch", @@ -68,10 +71,7 @@ public static IConfigurationContext CreateConfigurationContext( var search = new SearchConfiguration { Synonyms = [], Rules = [], DiminishTerms = [] }; return new ConfigurationContext { - Endpoints = new DocumentationEndpoints - { - Elasticsearch = ElasticsearchEndpoint.Default, - }, + Endpoints = new DocumentationEndpoints { Elasticsearch = ElasticsearchEndpoint.Default, }, ConfigurationFileProvider = new ConfigurationFileProvider(new TestLoggerFactory(null), new ConfigurationFileSystem(fileSystem)), VersionsConfiguration = versionsConfiguration, ProductsConfiguration = productsConfiguration, @@ -89,8 +89,10 @@ public sealed class ScopedTempDirectory : IDisposable { public ScopedTempDirectory(IFileSystem fileSystem, string prefix) { - Directory = fileSystem.DirectoryInfo.New( - fileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "test-temp", $"{prefix}-{Guid.NewGuid():N}")); + Directory = + fileSystem.DirectoryInfo.New( + fileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "test-temp", $"{prefix}-{Guid.NewGuid():N}") + ); Directory.Create(); } @@ -157,6 +159,11 @@ public class TestLogger(ITestOutputHelper? output) : ILogger { public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => - output?.WriteLine($"[{logLevel}] {formatter(state, exception)}"); + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) => output?.WriteLine($"[{logLevel}] {formatter(state, exception)}"); } diff --git a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs index 14bb6a8923..dd6594a5ac 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs @@ -77,11 +77,7 @@ public void IsValid_FalseWithMultipleEntries() { var sequence = new ApiProductSequence { - Entries = - [ - new ApiProductEntry { Product = "elasticsearch" }, - new ApiProductEntry { Product = "kibana" } - ] + Entries = [new ApiProductEntry { Product = "elasticsearch" }, new ApiProductEntry { Product = "kibana" }] }; sequence.IsValid.Should().BeFalse(); @@ -91,14 +87,13 @@ public void IsValid_FalseWithMultipleEntries() public class ApiConfigurationConverterTests { - private readonly IDeserializer _deserializer = new DeserializerBuilder() - .WithTypeConverter(new ApiConfigurationConverter()) - .Build(); + private readonly IDeserializer _deserializer = new DeserializerBuilder().WithTypeConverter(new ApiConfigurationConverter()).Build(); [Fact] public void AcceptsStrictEntry_WithSpecProductAndChildren() { - const string yaml = """ + const string yaml = + """ - spec: elasticsearch-openapi.json product: elasticsearch children: @@ -166,7 +161,8 @@ public void RecordsEntryAndProductMarks() [Fact] public void SkipsUnknownKeys() { - const string yaml = """ + const string yaml = + """ - spec: api.json product: elasticsearch unknown_key: some value @@ -180,7 +176,8 @@ public void SkipsUnknownKeys() [Fact] public void MultipleEntries_ParseButAreStructurallyInvalid() { - const string yaml = """ + const string yaml = + """ - spec: api1.json product: elasticsearch - spec: api2.json @@ -196,7 +193,8 @@ public void MultipleEntries_ParseButAreStructurallyInvalid() [Fact] public void AcceptsRepositoryOverride() { - const string yaml = """ + const string yaml = + """ - spec: elasticsearch-openapi.json product: elasticsearch repository: elastic/elasticsearch-specification @@ -253,8 +251,7 @@ public void RejectsLegacyIntroSpecOutroSequenceShape() var act = () => _deserializer.Deserialize(yaml); - act.Should().Throw() - .WithMessage("*legacy intro/outro shape*"); + act.Should().Throw().WithMessage("*legacy intro/outro shape*"); } } @@ -367,10 +364,7 @@ public void EmitsError_WhenSpecEscapesDocumentationSourceDirectory() { Api = new Dictionary { - ["elasticsearch"] = new() - { - Entries = [new ApiProductEntry { Spec = "../../outside.json", Product = "elasticsearch" }] - } + ["elasticsearch"] = new() { Entries = [new ApiProductEntry { Spec = "../../outside.json", Product = "elasticsearch" }] } } }; @@ -424,7 +418,10 @@ public void NormalizesUnderscoreProductId() { Api = new Dictionary { - ["dashboard"] = new() { Entries = [new ApiProductEntry { Spec = "dashboard-openapi.json", Product = "under_score_product" }] } + ["dashboard"] = new() + { + Entries = [new ApiProductEntry { Spec = "dashboard-openapi.json", Product = "under_score_product" }] + } } }; @@ -517,11 +514,7 @@ public void EmitsError_WhenMultipleEntries() { ["elasticsearch"] = new() { - Entries = - [ - new ApiProductEntry { Product = "elasticsearch" }, - new ApiProductEntry { Product = "elasticsearch" } - ] + Entries = [new ApiProductEntry { Product = "elasticsearch" }, new ApiProductEntry { Product = "elasticsearch" }] } } }; @@ -591,7 +584,10 @@ public void EmitsError_WhenChildPathEscapesApiKeyDirectory() private static readonly string[] DefaultProductIds = ["elasticsearch", "kibana"]; private static (ConfigurationFile Config, DiagnosticsCollector Collector) CreateConfiguration( - DocumentationSetFile docSet, string[]? extraProducts = null, bool withLocalSpecFile = true) + DocumentationSetFile docSet, + string[]? extraProducts = null, + bool withLocalSpecFile = true + ) { var collector = new DiagnosticsCollector([]); var root = Paths.WorkingDirectoryRoot.FullName; @@ -610,16 +606,10 @@ private static (ConfigurationFile Config, DiagnosticsCollector Collector) Create var docsDir = fileSystem.DirectoryInfo.New(Path.Join(root, "docs")); var context = new MockDocumentationSetContext(collector, fileSystem, configPath, docsDir); - var versionsConfig = new VersionsConfiguration - { - VersioningSystems = new Dictionary() - }; + var versionsConfig = new VersionsConfiguration { VersioningSystems = new Dictionary() }; var productIds = DefaultProductIds.Concat(extraProducts ?? []); - var products = productIds.ToDictionary( - id => id, - id => new Product { Id = id, DisplayName = id }, - StringComparer.OrdinalIgnoreCase); + var products = productIds.ToDictionary(id => id, id => new Product { Id = id, DisplayName = id }, StringComparer.OrdinalIgnoreCase); var productsConfig = new ProductsConfiguration { Products = products.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase), @@ -635,14 +625,18 @@ private sealed class MockDocumentationSetContext( IDiagnosticsCollector collector, IFileSystem fileSystem, IFileInfo configurationPath, - IDirectoryInfo documentationSourceDirectory) - : IDocumentationSetContext + IDirectoryInfo documentationSourceDirectory + ) : IDocumentationSetContext { public IDiagnosticsCollector Collector => collector; public IDocumentationFileSystem ReadFileSystem { get; } = DocumentationFileSystem.Resolve( documentationSourceDirectory, - new DocumentationScopeOptions { Inner = fileSystem, ConfigurationFile = configurationPath.FullName }); - public DocumentationWriteFileSystem WriteFileSystem { get; } = new(fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: fileSystem); + new DocumentationScopeOptions { Inner = fileSystem, ConfigurationFile = configurationPath.FullName } + ); + public DocumentationWriteFileSystem WriteFileSystem { get; } = new( + fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), + inner: fileSystem + ); public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); public IFileInfo ConfigurationPath => configurationPath; public BuildType BuildType => BuildType.Isolated; diff --git a/tests/Elastic.Documentation.Configuration.Tests/AssemblyConfigurationMatchTests.cs b/tests/Elastic.Documentation.Configuration.Tests/AssemblyConfigurationMatchTests.cs index fbece05e0c..30eaa81a28 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/AssemblyConfigurationMatchTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/AssemblyConfigurationMatchTests.cs @@ -13,7 +13,6 @@ namespace Elastic.Documentation.Configuration.Tests; - public class AssemblyConfigurationMatchTests { private static ILoggerFactory LoggerFactory => NullLoggerFactory.Instance; @@ -25,36 +24,22 @@ private static AssemblyConfiguration CreateConfiguration(Dictionary { - ["test-repo"] = new() - { - Name = "test-repo", - GitReferenceCurrent = "8.0", - GitReferenceNext = "8.1", - GitReferenceEdge = "main" - } + ["test-repo"] = new() { Name = "test-repo", GitReferenceCurrent = "8.0", GitReferenceNext = "8.1", GitReferenceEdge = "main" } }; - var config = new AssemblyConfiguration - { - ReferenceRepositories = repositories, - Narrative = new NarrativeRepository() - }; + var config = new AssemblyConfiguration { ReferenceRepositories = repositories, Narrative = new NarrativeRepository() }; // Simulate the deserialization process that sets AvailableRepositories - config.GetType().GetProperty("AvailableRepositories")! - .SetValue(config, repositories.Values.Concat([config.Narrative]).ToDictionary(r => r.Name, r => r)); + config.GetType().GetProperty("AvailableRepositories")!.SetValue( + config, + repositories.Values.Concat([config.Narrative]).ToDictionary(r => r.Name, r => r) + ); return config; } private static Repository CreateRepository(string current = "8.0", string next = "8.1", string edge = "main") => - new() - { - Name = "test-repo", - GitReferenceCurrent = current, - GitReferenceNext = next, - GitReferenceEdge = edge - }; + new() { Name = "test-repo", GitReferenceCurrent = current, GitReferenceNext = next, GitReferenceEdge = edge }; private static Product CreateProduct(SemVersion currentVersion) => new() @@ -139,18 +124,16 @@ public void MatchesMultipleContentSourcesWhenBranchMatchesAll() var result = config.Match(LoggerFactory, "elastic/test-repo", "main", null, false); - result.Should().BeEquivalentTo(new MatchResult( - ContentSource.Current, - ContentSource.Next, - ContentSource.Edge, - false - )); + result.Should().BeEquivalentTo(new MatchResult(ContentSource.Current, ContentSource.Next, ContentSource.Edge, false)); } [Theory] - [InlineData("8.15", "8.0", true)] // Greater than current + [InlineData("8.15", "8.0", true)] // Greater than current + [InlineData("8.15", "8.15", true)] // Equal to current + [InlineData("8.0", "8.15", false)] // Less than current + public void VersionBranchSpeculativeBuildBasedOnCurrentVersion(string branch, string currentVersion, bool shouldBeSpeculative) { var repositories = new Dictionary @@ -165,11 +148,16 @@ public void VersionBranchSpeculativeBuildBasedOnCurrentVersion(string branch, st } [Theory] - [InlineData("8.16", "8.15", true)] // Greater than product version + [InlineData("8.16", "8.15", true)] // Greater than product version + [InlineData("8.15", "8.15", false)] // Equal to product version — current is served from main + [InlineData("8.14", "8.15", false)] // Previous minor version - but current is not versioned, so no previous minor logic + [InlineData("8.13", "8.15", false)] // Less than previous minor - [InlineData("8.0", "8.0", false)] // Edge case: equal at minor version 0 + + [InlineData("8.0", "8.0", false)] // Edge case: equal at minor version 0 + public void VersionBranchSpeculativeBuildBasedOnProductVersion(string branch, string productVersion, bool shouldBeSpeculative) { var repositories = new Dictionary @@ -268,8 +256,10 @@ public void CurrentVersionMatchAlsoSetsSpeculative() } [Theory] - [InlineData("9.1", "9.0.0")] // Greater than anchored product version - [InlineData("9.5", "9.0.0")] // Much greater than anchored product version + [InlineData("9.1", "9.0.0")] // Greater than anchored product version + + [InlineData("9.5", "9.0.0")] // Much greater than anchored product version + public void VersionBranchSpeculativeBuildWhenGreaterThanAnchoredProductVersion(string branch, string productVersion) { var repositories = new Dictionary @@ -278,7 +268,9 @@ public void VersionBranchSpeculativeBuildWhenGreaterThanAnchoredProductVersion(s }; var config = CreateConfiguration(repositories); var versionParts = productVersion.Split('.'); - var product = CreateProduct(new SemVersion(int.Parse(versionParts[0], null), int.Parse(versionParts[1], null), int.Parse(versionParts[2], null))); + var product = CreateProduct( + new SemVersion(int.Parse(versionParts[0], null), int.Parse(versionParts[1], null), int.Parse(versionParts[2], null)) + ); var result = config.Match(LoggerFactory, "elastic/test-repo", branch, product, false); @@ -286,11 +278,18 @@ public void VersionBranchSpeculativeBuildWhenGreaterThanAnchoredProductVersion(s } [Theory] - [InlineData("8.15", "9.0.0")] // Less than anchored product version - [InlineData("7.17", "9.0.0")] // Much less than anchored product version - [InlineData("8.0", "9.1.5")] // Less than anchored product version with patch - [InlineData("9.0", "9.0.0")] // Equal to anchored product version — current is served from main - public void VersionBranchNoSpeculativeBuildWhenLessThanOrEqualToAnchoredProductVersionAndNotPreviousMinor(string branch, string productVersion) + [InlineData("8.15", "9.0.0")] // Less than anchored product version + + [InlineData("7.17", "9.0.0")] // Much less than anchored product version + + [InlineData("8.0", "9.1.5")] // Less than anchored product version with patch + + [InlineData("9.0", "9.0.0")] // Equal to anchored product version — current is served from main + + public void VersionBranchNoSpeculativeBuildWhenLessThanOrEqualToAnchoredProductVersionAndNotPreviousMinor( + string branch, + string productVersion + ) { var repositories = new Dictionary { @@ -298,7 +297,9 @@ public void VersionBranchNoSpeculativeBuildWhenLessThanOrEqualToAnchoredProductV }; var config = CreateConfiguration(repositories); var versionParts = productVersion.Split('.'); - var product = CreateProduct(new SemVersion(int.Parse(versionParts[0], null), int.Parse(versionParts[1], null), int.Parse(versionParts[2], null))); + var product = CreateProduct( + new SemVersion(int.Parse(versionParts[0], null), int.Parse(versionParts[1], null), int.Parse(versionParts[2], null)) + ); var result = config.Match(LoggerFactory, "elastic/test-repo", branch, product, false); @@ -306,9 +307,12 @@ public void VersionBranchNoSpeculativeBuildWhenLessThanOrEqualToAnchoredProductV } [Theory] - [InlineData("9.1", "9.2")] // Previous minor version - current is versioned branch + [InlineData("9.1", "9.2")] // Previous minor version - current is versioned branch + [InlineData("8.14", "8.15")] // Previous minor version - current is versioned branch + [InlineData("10.0", "10.1")] // Previous minor version at major boundary - current is versioned branch + public void VersionBranchSpeculativeBuildWhenMatchesPreviousMinorVersion(string branch, string currentVersion) { var repositories = new Dictionary @@ -334,7 +338,8 @@ public void VersionBranchNoSpeculativeBuildWhenProductVersioningSystemIsNull() { Id = "test-product", DisplayName = "Test Product", - VersioningSystem = null // No versioning system + VersioningSystem = + null // No versioning system }; var result = config.Match(LoggerFactory, "elastic/test-repo", "9.0", product, false); @@ -357,9 +362,12 @@ public void VersionBranchNoSpeculativeBuildWhenProductIsNull() } [Theory] - [InlineData("9.1", "9.0.15")] // Anchored to 9.0.0, branch 9.1 > 9.0.0 - [InlineData("9.1", "9.0.0")] // Anchored to 9.0.0, branch 9.1 > 9.0.0 - [InlineData("9.1", "9.0.1")] // Anchored to 9.0.0, branch 9.1 > 9.0.0 + [InlineData("9.1", "9.0.15")] // Anchored to 9.0.0, branch 9.1 > 9.0.0 + + [InlineData("9.1", "9.0.0")] // Anchored to 9.0.0, branch 9.1 > 9.0.0 + + [InlineData("9.1", "9.0.1")] // Anchored to 9.0.0, branch 9.1 > 9.0.0 + public void VersionBranchAnchorsProductVersionToMinorZero(string branch, string productVersion) { var repositories = new Dictionary @@ -368,7 +376,9 @@ public void VersionBranchAnchorsProductVersionToMinorZero(string branch, string }; var config = CreateConfiguration(repositories); var versionParts = productVersion.Split('.'); - var product = CreateProduct(new SemVersion(int.Parse(versionParts[0], null), int.Parse(versionParts[1], null), int.Parse(versionParts[2], null))); + var product = CreateProduct( + new SemVersion(int.Parse(versionParts[0], null), int.Parse(versionParts[1], null), int.Parse(versionParts[2], null)) + ); var result = config.Match(LoggerFactory, "elastic/test-repo", branch, product, false); @@ -376,8 +386,10 @@ public void VersionBranchAnchorsProductVersionToMinorZero(string branch, string } [Theory] - [InlineData("8.0", "8.1")] // Previous minor when current is 8.1 + [InlineData("8.0", "8.1")] // Previous minor when current is 8.1 + [InlineData("7.17", "8.0")] // NOT previous minor when current is 8.0 (previous would be 7.0, not 7.17) + public void VersionBranchPreviousMinorCalculationHandlesEdgeCases(string branch, string currentVersion) { var repositories = new Dictionary @@ -395,8 +407,10 @@ public void VersionBranchPreviousMinorCalculationHandlesEdgeCases(string branch, } [Theory] - [InlineData("9.1", "9.0.0")] // Greater than anchored product version - [InlineData("9.5", "9.0.0")] // Much greater than anchored product version + [InlineData("9.1", "9.0.0")] // Greater than anchored product version + + [InlineData("9.5", "9.0.0")] // Much greater than anchored product version + public void AlreadyPublishingTruePreventSpeculativeBuildForVersionBranch(string branch, string productVersion) { var repositories = new Dictionary @@ -405,7 +419,9 @@ public void AlreadyPublishingTruePreventSpeculativeBuildForVersionBranch(string }; var config = CreateConfiguration(repositories); var versionParts = productVersion.Split('.'); - var product = CreateProduct(new SemVersion(int.Parse(versionParts[0], null), int.Parse(versionParts[1], null), int.Parse(versionParts[2], null))); + var product = CreateProduct( + new SemVersion(int.Parse(versionParts[0], null), int.Parse(versionParts[1], null), int.Parse(versionParts[2], null)) + ); var result = config.Match(LoggerFactory, "elastic/test-repo", branch, product, true); @@ -413,8 +429,10 @@ public void AlreadyPublishingTruePreventSpeculativeBuildForVersionBranch(string } [Theory] - [InlineData("9.1", "9.0.0")] // Greater than anchored product version - [InlineData("9.5", "9.0.0")] // Much greater than anchored product version + [InlineData("9.1", "9.0.0")] // Greater than anchored product version + + [InlineData("9.5", "9.0.0")] // Much greater than anchored product version + public void AlreadyPublishingFalseAllowsSpeculativeBuildForVersionBranch(string branch, string productVersion) { var repositories = new Dictionary @@ -423,7 +441,9 @@ public void AlreadyPublishingFalseAllowsSpeculativeBuildForVersionBranch(string }; var config = CreateConfiguration(repositories); var versionParts = productVersion.Split('.'); - var product = CreateProduct(new SemVersion(int.Parse(versionParts[0], null), int.Parse(versionParts[1], null), int.Parse(versionParts[2], null))); + var product = CreateProduct( + new SemVersion(int.Parse(versionParts[0], null), int.Parse(versionParts[1], null), int.Parse(versionParts[2], null)) + ); var result = config.Match(LoggerFactory, "elastic/test-repo", branch, product, false); diff --git a/tests/Elastic.Documentation.Configuration.Tests/ChangelogTemplateSeederTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ChangelogTemplateSeederTests.cs index 2f0e7df3e7..cb4e8c857a 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ChangelogTemplateSeederTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ChangelogTemplateSeederTests.cs @@ -8,8 +8,7 @@ namespace Elastic.Documentation.Configuration.Tests; public class ChangelogTemplateSeederTests { - private const string Template = - "bundle:\n release_dates: true\n # changelog-init-bundle-seed\n # some other comment\n"; + private const string Template = "bundle:\n release_dates: true\n # changelog-init-bundle-seed\n # some other comment\n"; private const string TemplateWindows = "bundle:\r\n release_dates: true\r\n # changelog-init-bundle-seed\r\n # some other comment\r\n"; @@ -18,7 +17,12 @@ public class ChangelogTemplateSeederTests public void ApplyBundleRepoSeed_GitOwnerAndRepo_SeedsTemplate() { var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( - Template, ownerCli: null, repoCli: null, gitOwner: "elastic", gitRepo: "kibana"); + Template, + ownerCli: null, + repoCli: null, + gitOwner: "elastic", + gitRepo: "kibana" + ); result.Should().Contain(" owner: elastic\n"); result.Should().Contain(" repo: kibana\n"); @@ -30,7 +34,12 @@ public void ApplyBundleRepoSeed_GitOwnerAndRepo_SeedsTemplate() public void ApplyBundleRepoSeed_CliOwnerAndRepo_SeedsTemplate() { var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( - Template, ownerCli: "myorg", repoCli: "myrepo", gitOwner: null, gitRepo: null); + Template, + ownerCli: "myorg", + repoCli: "myrepo", + gitOwner: null, + gitRepo: null + ); result.Should().Contain(" owner: myorg\n"); result.Should().Contain(" repo: myrepo\n"); @@ -41,7 +50,12 @@ public void ApplyBundleRepoSeed_CliOwnerAndRepo_SeedsTemplate() public void ApplyBundleRepoSeed_CliOverridesGit() { var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( - Template, ownerCli: "override-owner", repoCli: "override-repo", gitOwner: "elastic", gitRepo: "kibana"); + Template, + ownerCli: "override-owner", + repoCli: "override-repo", + gitOwner: "elastic", + gitRepo: "kibana" + ); result.Should().Contain(" owner: override-owner\n"); result.Should().Contain(" repo: override-repo\n"); @@ -52,7 +66,12 @@ public void ApplyBundleRepoSeed_CliOverridesGit() public void ApplyBundleRepoSeed_CliRepoOnly_OwnerDefaultsToElastic() { var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( - Template, ownerCli: null, repoCli: "myrepo", gitOwner: null, gitRepo: null); + Template, + ownerCli: null, + repoCli: "myrepo", + gitOwner: null, + gitRepo: null + ); result.Should().Contain(" owner: elastic\n"); result.Should().Contain(" repo: myrepo\n"); @@ -62,8 +81,7 @@ public void ApplyBundleRepoSeed_CliRepoOnly_OwnerDefaultsToElastic() [Fact] public void ApplyBundleRepoSeed_CliOwnerOnly_NoRepo_RemovesPlaceholder() { - var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( - Template, ownerCli: "myorg", repoCli: null, gitOwner: null, gitRepo: null); + var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed(Template, ownerCli: "myorg", repoCli: null, gitOwner: null, gitRepo: null); result.Should().NotContain("changelog-init-bundle-seed"); result.Should().NotContain(" owner:"); @@ -73,8 +91,7 @@ public void ApplyBundleRepoSeed_CliOwnerOnly_NoRepo_RemovesPlaceholder() [Fact] public void ApplyBundleRepoSeed_NeitherCliNorGit_RemovesPlaceholder() { - var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( - Template, ownerCli: null, repoCli: null, gitOwner: null, gitRepo: null); + var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed(Template, ownerCli: null, repoCli: null, gitOwner: null, gitRepo: null); result.Should().NotContain("changelog-init-bundle-seed"); result.Should().NotContain(" owner:"); @@ -86,7 +103,12 @@ public void ApplyBundleRepoSeed_NeitherCliNorGit_RemovesPlaceholder() public void ApplyBundleRepoSeed_WhitespaceCliValues_TreatedAsAbsent() { var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( - Template, ownerCli: " ", repoCli: " ", gitOwner: "elastic", gitRepo: "kibana"); + Template, + ownerCli: " ", + repoCli: " ", + gitOwner: "elastic", + gitRepo: "kibana" + ); result.Should().Contain(" owner: elastic\n"); result.Should().Contain(" repo: kibana\n"); @@ -96,7 +118,12 @@ public void ApplyBundleRepoSeed_WhitespaceCliValues_TreatedAsAbsent() public void ApplyBundleRepoSeed_WindowsLineEndings_PreservesStyle() { var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( - TemplateWindows, ownerCli: null, repoCli: null, gitOwner: "elastic", gitRepo: "kibana"); + TemplateWindows, + ownerCli: null, + repoCli: null, + gitOwner: "elastic", + gitRepo: "kibana" + ); result.Should().Contain(" owner: elastic\r\n"); result.Should().Contain(" repo: kibana\r\n"); @@ -109,7 +136,12 @@ public void ApplyBundleRepoSeed_MissingPlaceholder_ReturnsContentUnchanged() var content = "bundle:\n release_dates: true\n"; var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( - content, ownerCli: "elastic", repoCli: "kibana", gitOwner: null, gitRepo: null); + content, + ownerCli: "elastic", + repoCli: "kibana", + gitOwner: null, + gitRepo: null + ); result.Should().Be(content); } @@ -118,7 +150,12 @@ public void ApplyBundleRepoSeed_MissingPlaceholder_ReturnsContentUnchanged() public void ApplyBundleRepoSeed_ValuesNeedingYamlQuoting_AreQuoted() { var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( - Template, ownerCli: "my org", repoCli: "my:repo", gitOwner: null, gitRepo: null); + Template, + ownerCli: "my org", + repoCli: "my:repo", + gitOwner: null, + gitRepo: null + ); result.Should().Contain(" owner: \"my org\"\n"); result.Should().Contain(" repo: \"my:repo\"\n"); @@ -129,7 +166,12 @@ public void ApplyBundleRepoSeed_ValuesNeedingYamlQuoting_AreQuoted() public void ApplyBundleRepoSeed_CliRepoOverridesGitRepo_KeepsGitOwner() { var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( - Template, ownerCli: null, repoCli: "other-repo", gitOwner: "elastic", gitRepo: "kibana"); + Template, + ownerCli: null, + repoCli: "other-repo", + gitOwner: "elastic", + gitRepo: "kibana" + ); result.Should().Contain(" owner: elastic\n"); result.Should().Contain(" repo: other-repo\n"); @@ -142,7 +184,12 @@ public void ApplyBundleRepoSeed_PlaceholderAtEofWithoutNewline_Seeds() var content = "bundle:\n release_dates: true\n # changelog-init-bundle-seed"; var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( - content, ownerCli: null, repoCli: null, gitOwner: "elastic", gitRepo: "kibana"); + content, + ownerCli: null, + repoCli: null, + gitOwner: "elastic", + gitRepo: "kibana" + ); result.Should().Contain(" owner: elastic"); result.Should().Contain(" repo: kibana"); @@ -154,8 +201,7 @@ public void ApplyBundleRepoSeed_PlaceholderAtEofWithoutNewline_RemovesWhenNoSeed { var content = "bundle:\n release_dates: true\n # changelog-init-bundle-seed"; - var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( - content, ownerCli: null, repoCli: null, gitOwner: null, gitRepo: null); + var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed(content, ownerCli: null, repoCli: null, gitOwner: null, gitRepo: null); result.Should().Be("bundle:\n release_dates: true\n"); result.Should().NotContain("changelog-init-bundle-seed"); @@ -165,7 +211,12 @@ public void ApplyBundleRepoSeed_PlaceholderAtEofWithoutNewline_RemovesWhenNoSeed public void ApplyBundleRepoSeed_BackslashInValue_IsEscapedInYaml() { var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( - Template, ownerCli: @"path\org", repoCli: "repo", gitOwner: null, gitRepo: null); + Template, + ownerCli: @"path\org", + repoCli: "repo", + gitOwner: null, + gitRepo: null + ); result.Should().Contain(@" owner: ""path\\org"""); result.Should().Contain(" repo: repo\n"); @@ -175,7 +226,12 @@ public void ApplyBundleRepoSeed_BackslashInValue_IsEscapedInYaml() public void ApplyBundleRepoSeed_ControlCharsInValue_AreEscapedInYaml() { var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( - Template, ownerCli: "org\tname", repoCli: "repo", gitOwner: null, gitRepo: null); + Template, + ownerCli: "org\tname", + repoCli: "repo", + gitOwner: null, + gitRepo: null + ); result.Should().Contain(@" owner: ""org\tname"""); } diff --git a/tests/Elastic.Documentation.Configuration.Tests/CheckoutsFileSystemTests.cs b/tests/Elastic.Documentation.Configuration.Tests/CheckoutsFileSystemTests.cs index a3da9685d6..287ee08f30 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/CheckoutsFileSystemTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/CheckoutsFileSystemTests.cs @@ -17,10 +17,7 @@ public void NestedExtensionRoot_DoesNotThrow() var workingRoot = Paths.WorkingDirectoryRoot.FullName; var nestedConfigDir = Path.Join(workingRoot, "environments", "internal"); var configPath = Path.Join(nestedConfigDir, "config.yml"); - var mockFs = new MockFileSystem(new Dictionary - { - { configPath, new MockFileData("environment: internal") } - }); + var mockFs = new MockFileSystem(new Dictionary { { configPath, new MockFileData("environment: internal") } }); var act = () => new CheckoutsFileSystem(mockFs.DirectoryInfo.New(workingRoot), inner: mockFs, extraRoots: [nestedConfigDir]); @@ -35,10 +32,7 @@ public void ExternalExtensionRoot_AllowsReadingExternalConfig() var workingRoot = Paths.WorkingDirectoryRoot.FullName; var externalRoot = Path.Join(Path.GetTempPath(), $"external-codex-{Guid.NewGuid():N}"); var configPath = Path.Join(externalRoot, "codex.yml"); - var mockFs = new MockFileSystem(new Dictionary - { - { configPath, new MockFileData("environment: internal") } - }); + var mockFs = new MockFileSystem(new Dictionary { { configPath, new MockFileData("environment: internal") } }); var scoped = new CheckoutsFileSystem(mockFs.DirectoryInfo.New(workingRoot), inner: mockFs, extraRoots: [externalRoot]); @@ -71,12 +65,16 @@ public void ExtraRunnerTempRoot_AllowsReadingStagedFile() // plain working-dir scope denies. var tempDir = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); var stagedFile = Path.Join(tempDir, "changelog-pr-body.md"); - var mockFs = new MockFileSystem(new Dictionary - { - { stagedFile, new MockFileData("Release Notes: fix memory leak") } - }, new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); + var mockFs = new MockFileSystem( + new Dictionary { { stagedFile, new MockFileData("Release Notes: fix memory leak") } }, + new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName } + ); - var scoped = new CheckoutsFileSystem(mockFs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: mockFs, extraRoots: [tempDir]); + var scoped = new CheckoutsFileSystem( + mockFs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), + inner: mockFs, + extraRoots: [tempDir] + ); scoped.File.Exists(stagedFile).Should().BeTrue(); } diff --git a/tests/Elastic.Documentation.Configuration.Tests/CiCheckoutLayoutTests.cs b/tests/Elastic.Documentation.Configuration.Tests/CiCheckoutLayoutTests.cs index 9acb0e0caf..1c0c610cdd 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/CiCheckoutLayoutTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/CiCheckoutLayoutTests.cs @@ -28,8 +28,7 @@ public class CiCheckoutLayoutTests /// Constructs the simulated CI checkout path: a directory nested inside the real /// ApplicationData folder, matching the layout the assembler uses on hosted runners. /// - private static string CiCheckoutRoot => - Path.Join(Paths.ApplicationData.FullName, "checkouts", "current", "apm-server"); + private static string CiCheckoutRoot => Path.Join(Paths.ApplicationData.FullName, "checkouts", "current", "apm-server"); // ----------------------------------------------------------------------- // CheckoutsFileSystem @@ -54,10 +53,7 @@ public void CheckoutsFileSystem_CheckoutInsideAppData_ReadsFilesUnderCheckout() { var checkoutRoot = CiCheckoutRoot; var filePath = Path.Join(checkoutRoot, "readme.md"); - var mockFs = new MockFileSystem(new Dictionary - { - { filePath, new MockFileData("hello") } - }); + var mockFs = new MockFileSystem(new Dictionary { { filePath, new MockFileData("hello") } }); var fs = new CheckoutsFileSystem(mockFs.DirectoryInfo.New(checkoutRoot), inner: mockFs); @@ -74,9 +70,7 @@ public void DocumentationWriteFileSystem_CheckoutInsideAppData_DoesNotThrow() var checkoutRoot = CiCheckoutRoot; var mockFs = new MockFileSystem(); - var act = () => new DocumentationWriteFileSystem( - mockFs.DirectoryInfo.New(checkoutRoot), - inner: mockFs); + var act = () => new DocumentationWriteFileSystem(mockFs.DirectoryInfo.New(checkoutRoot), inner: mockFs); act.Should().NotThrow(); } @@ -88,9 +82,7 @@ public void DocumentationWriteFileSystem_CheckoutInsideAppData_WritesFilesUnderC var outputPath = Path.Join(checkoutRoot, ".artifacts", "docs", "html"); var mockFs = new MockFileSystem(); - var writeFs = new DocumentationWriteFileSystem( - mockFs.DirectoryInfo.New(checkoutRoot), - inner: mockFs); + var writeFs = new DocumentationWriteFileSystem(mockFs.DirectoryInfo.New(checkoutRoot), inner: mockFs); var act = () => writeFs.Directory.CreateDirectory(outputPath); act.Should().NotThrow(); @@ -107,9 +99,8 @@ public void DocumentationFileSystem_Resolve_CheckoutInsideAppData_DoesNotThrow() var docsPath = Path.Join(checkoutRoot, "docs"); var mockFs = BuildDocsetFs(checkoutRoot, docsPath); - var act = () => DocumentationFileSystem.Resolve( - mockFs.DirectoryInfo.New(docsPath), - new DocumentationScopeOptions { Inner = mockFs }); + var act = + () => DocumentationFileSystem.Resolve(mockFs.DirectoryInfo.New(docsPath), new DocumentationScopeOptions { Inner = mockFs }); act.Should().NotThrow(); } @@ -121,9 +112,7 @@ public void DocumentationFileSystem_Resolve_CheckoutInsideAppData_CheckoutResolv var docsPath = Path.Join(checkoutRoot, "docs"); var mockFs = BuildDocsetFs(checkoutRoot, docsPath); - var docFs = DocumentationFileSystem.Resolve( - mockFs.DirectoryInfo.New(docsPath), - new DocumentationScopeOptions { Inner = mockFs }); + var docFs = DocumentationFileSystem.Resolve(mockFs.DirectoryInfo.New(docsPath), new DocumentationScopeOptions { Inner = mockFs }); docFs.Paths.CheckoutDirectory.FullName.Should().Be(checkoutRoot); } @@ -139,7 +128,8 @@ public void DocumentationFileSystem_Resolve_CheckoutInsideAppData_WriteDoesNotTh { var docFs = DocumentationFileSystem.Resolve( mockFs.DirectoryInfo.New(docsPath), - new DocumentationScopeOptions { Inner = mockFs }); + new DocumentationScopeOptions { Inner = mockFs } + ); // accessing .Write must not throw either _ = docFs.Write; }; @@ -155,18 +145,20 @@ private static MockFileSystem BuildDocsetFs(string checkoutRoot, string docsPath { var mockFs = new MockFileSystem(); mockFs.AddDirectory(Path.Join(checkoutRoot, ".git")); - mockFs.AddFile(Path.Join(checkoutRoot, ".git", "HEAD"), - new MockFileData("ref: refs/heads/main\n")); - mockFs.AddFile(Path.Join(checkoutRoot, ".git", "refs", "heads", "main"), - new MockFileData("abc1234\n")); - mockFs.AddFile(Path.Join(checkoutRoot, ".git", "config"), - new MockFileData(""" + mockFs.AddFile(Path.Join(checkoutRoot, ".git", "HEAD"), new MockFileData("ref: refs/heads/main\n")); + mockFs.AddFile(Path.Join(checkoutRoot, ".git", "refs", "heads", "main"), new MockFileData("abc1234\n")); + mockFs.AddFile( + Path.Join(checkoutRoot, ".git", "config"), + new MockFileData( + """ [remote "origin"] url = https://github.com/elastic/apm-server.git [branch "main"] remote = origin merge = refs/heads/main - """)); + """ + ) + ); mockFs.AddFile(Path.Join(docsPath, "docset.yml"), new MockFileData("toc: []\n")); return mockFs; } diff --git a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileExcludeTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileExcludeTests.cs index 1d92535ac1..2119594556 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileExcludeTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileExcludeTests.cs @@ -24,10 +24,7 @@ public void IsExcluded_DocsetGlob_MatchesNestedKibanaDocsPath() { Project = "test", TableOfContents = [], - Exclude = - [ - "reference/query-languages/esql/kibana/docs/**" - ] + Exclude = ["reference/query-languages/esql/kibana/docs/**"] }; var config = CreateConfiguration(docSet); @@ -41,10 +38,7 @@ public void IsExcluded_DocsetGlob_DoesNotMatchOutsideTree() { Project = "test", TableOfContents = [], - Exclude = - [ - "reference/query-languages/esql/kibana/docs/**" - ] + Exclude = ["reference/query-languages/esql/kibana/docs/**"] }; var config = CreateConfiguration(docSet); @@ -56,19 +50,13 @@ private static ConfigurationFile CreateConfiguration(DocumentationSetFile docSet var collector = new DiagnosticsCollector([]); var root = Paths.WorkingDirectoryRoot.FullName; var configFilePath = Path.Join(root, "docs", "_docset.yml"); - var fileSystem = new MockFileSystem(new Dictionary - { - { configFilePath, new MockFileData("") } - }, root); + var fileSystem = new MockFileSystem(new Dictionary { { configFilePath, new MockFileData("") } }, root); var configPath = fileSystem.FileInfo.New(configFilePath); var docsDir = fileSystem.DirectoryInfo.New(Path.Join(root, "docs")); var context = new MockDocumentationSetContext(collector, fileSystem, configPath, docsDir); - var versionsConfig = new VersionsConfiguration - { - VersioningSystems = new Dictionary() - }; + var versionsConfig = new VersionsConfiguration { VersioningSystems = new Dictionary() }; var productsConfig = new ProductsConfiguration { Products = new Dictionary().ToFrozenDictionary(), @@ -83,13 +71,15 @@ private sealed class MockDocumentationSetContext( IDiagnosticsCollector collector, IFileSystem fileSystem, IFileInfo configurationPath, - IDirectoryInfo documentationSourceDirectory) - : IDocumentationSetContext + IDirectoryInfo documentationSourceDirectory + ) : IDocumentationSetContext { public IDiagnosticsCollector Collector => collector; public IDocumentationFileSystem ReadFileSystem { get; } = DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName); public DocumentationWriteFileSystem WriteFileSystem { get; } = new DocumentationWriteFileSystem( - fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: fileSystem); + fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), + inner: fileSystem + ); public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); public IFileInfo ConfigurationPath => configurationPath; public BuildType BuildType => BuildType.Isolated; diff --git a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileReleaseNotesTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileReleaseNotesTests.cs index b541ccb920..a416c5d39c 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileReleaseNotesTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileReleaseNotesTests.cs @@ -50,8 +50,7 @@ public async Task ReleaseNotes_UnknownProduct_EmitsError() var (config, diagnostics) = await CreateConfiguration(DocSetWithReleaseNotes("not-a-product")); config.ReleaseNotesProducts.Should().BeEmpty(); - diagnostics.Should().Contain(d => - d.Severity == Severity.Error && d.Message.Contains("Unknown 'release_notes' product")); + diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("Unknown 'release_notes' product")); } [Fact] @@ -60,8 +59,7 @@ public async Task ReleaseNotes_ProductWithoutReleaseNotesFeature_EmitsError() var (config, diagnostics) = await CreateConfiguration(DocSetWithReleaseNotes("reference-only")); config.ReleaseNotesProducts.Should().BeEmpty(); - diagnostics.Should().Contain(d => - d.Severity == Severity.Error && d.Message.Contains("does not participate")); + diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("does not participate")); } [Fact] @@ -70,8 +68,7 @@ public async Task ReleaseNotes_InvalidProductId_EmitsError() var (config, diagnostics) = await CreateConfiguration(DocSetWithReleaseNotes("bad/slug")); config.ReleaseNotesProducts.Should().BeEmpty(); - diagnostics.Should().Contain(d => - d.Severity == Severity.Error && d.Message.Contains("must match")); + diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("must match")); } [Fact] @@ -80,8 +77,7 @@ public async Task ReleaseNotes_EmptyProductValue_EmitsError() var (config, diagnostics) = await CreateConfiguration(DocSetWithReleaseNotes(" ")); config.ReleaseNotesProducts.Should().BeEmpty(); - diagnostics.Should().Contain(d => - d.Severity == Severity.Error && d.Message.Contains("missing a 'product' value")); + diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("missing a 'product' value")); } private static DocumentationSetFile DocSetWithReleaseNotes(params string[] products) => @@ -92,7 +88,9 @@ private static DocumentationSetFile DocSetWithReleaseNotes(params string[] produ ReleaseNotes = [.. products.Select(p => new ReleaseNotesProductReference { Product = p })] }; - private static async Task<(ConfigurationFile Config, IReadOnlyList Diagnostics)> CreateConfiguration(DocumentationSetFile docSet) + private static async Task<(ConfigurationFile Config, IReadOnlyList Diagnostics)> CreateConfiguration( + DocumentationSetFile docSet + ) { var recorder = new RecordingDiagnosticsOutput(); var collector = new DiagnosticsCollector([recorder]); @@ -100,19 +98,13 @@ private static DocumentationSetFile DocSetWithReleaseNotes(params string[] produ var root = Paths.WorkingDirectoryRoot.FullName; var configFilePath = Path.Join(root, "docs", "_docset.yml"); - var fileSystem = new MockFileSystem(new Dictionary - { - { configFilePath, new MockFileData("") } - }, root); + var fileSystem = new MockFileSystem(new Dictionary { { configFilePath, new MockFileData("") } }, root); var configPath = fileSystem.FileInfo.New(configFilePath); var docsDir = fileSystem.DirectoryInfo.New(Path.Join(root, "docs")); var context = new MockDocumentationSetContext(collector, fileSystem, configPath, docsDir); - var versionsConfig = new VersionsConfiguration - { - VersioningSystems = new Dictionary() - }; + var versionsConfig = new VersionsConfiguration { VersioningSystems = new Dictionary() }; var productsConfig = CreateProductsConfiguration(); var config = new ConfigurationFile(docSet, context, versionsConfig, productsConfig); @@ -152,13 +144,15 @@ private sealed class MockDocumentationSetContext( IDiagnosticsCollector collector, IFileSystem fileSystem, IFileInfo configurationPath, - IDirectoryInfo documentationSourceDirectory) - : IDocumentationSetContext + IDirectoryInfo documentationSourceDirectory + ) : IDocumentationSetContext { public IDiagnosticsCollector Collector => collector; public IDocumentationFileSystem ReadFileSystem { get; } = DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName); public DocumentationWriteFileSystem WriteFileSystem { get; } = new DocumentationWriteFileSystem( - fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: fileSystem); + fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), + inner: fileSystem + ); public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); public IFileInfo ConfigurationPath => configurationPath; public BuildType BuildType => BuildType.Isolated; diff --git a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileStorybookRegistryTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileStorybookRegistryTests.cs index af6bedef33..114bbf54c1 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileStorybookRegistryTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileStorybookRegistryTests.cs @@ -43,22 +43,27 @@ public void SetVariable_ResolvesToEnvironmentValue_AndExposesDefaultAsFallback() public void DisallowedVariable_IsLeftLiteral_AndWarns() { var collector = new DiagnosticsCollector([]); - var config = CreateConfiguration("${AWS_SECRET_ACCESS_KEY:-fallback}", new MockEnvironment { ["AWS_SECRET_ACCESS_KEY"] = "super-secret" }, collector); + var config = CreateConfiguration( + "${AWS_SECRET_ACCESS_KEY:-fallback}", + new MockEnvironment { ["AWS_SECRET_ACCESS_KEY"] = "super-secret" }, + collector + ); config.StorybookRegistry.Should().Be("${AWS_SECRET_ACCESS_KEY:-fallback}"); config.StorybookRegistry.Should().NotContain("super-secret"); collector.Warnings.Should().Be(1, "a disallowed interpolation variable must emit exactly one warning"); } - private static ConfigurationFile CreateConfiguration(string registry, IEnvironmentVariables environment, DiagnosticsCollector? collector = null) + private static ConfigurationFile CreateConfiguration( + string registry, + IEnvironmentVariables environment, + DiagnosticsCollector? collector = null + ) { collector ??= new DiagnosticsCollector([]); var root = Paths.WorkingDirectoryRoot.FullName; var configFilePath = Path.Join(root, "docs", "_docset.yml"); - var fileSystem = new MockFileSystem(new Dictionary - { - { configFilePath, new MockFileData("") } - }, root); + var fileSystem = new MockFileSystem(new Dictionary { { configFilePath, new MockFileData("") } }, root); var configPath = fileSystem.FileInfo.New(configFilePath); var docsDir = fileSystem.DirectoryInfo.New(Path.Join(root, "docs")); @@ -86,10 +91,7 @@ private sealed class MockEnvironment : IEnvironmentVariables { private readonly Dictionary _variables = [with(StringComparer.Ordinal)]; - public string? this[string name] - { - set => _variables[name] = value; - } + public string? this[string name] { set => _variables[name] = value; } public string? GetEnvironmentVariable(string name) => _variables.GetValueOrDefault(name); @@ -101,13 +103,15 @@ private sealed class MockDocumentationSetContext( IFileSystem fileSystem, IFileInfo configurationPath, IDirectoryInfo documentationSourceDirectory, - IEnvironmentVariables environment) - : IDocumentationSetContext + IEnvironmentVariables environment + ) : IDocumentationSetContext { public IDiagnosticsCollector Collector => collector; public IDocumentationFileSystem ReadFileSystem { get; } = DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName); public DocumentationWriteFileSystem WriteFileSystem { get; } = new DocumentationWriteFileSystem( - fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: fileSystem); + fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), + inner: fileSystem + ); public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); public IFileInfo ConfigurationPath => configurationPath; public BuildType BuildType => BuildType.Isolated; diff --git a/tests/Elastic.Documentation.Configuration.Tests/CreateNavigationFileTests.cs b/tests/Elastic.Documentation.Configuration.Tests/CreateNavigationFileTests.cs index e0c8d2a9d4..ecc3136746 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/CreateNavigationFileTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/CreateNavigationFileTests.cs @@ -13,7 +13,12 @@ namespace Elastic.Documentation.Configuration.Tests; public class CreateNavigationFileTests { private static ConfigurationFileProvider CreateProvider(MockFileSystem fileSystem) => - new(NullLoggerFactory.Instance, new ConfigurationFileSystem(fileSystem), skipPrivateRepositories: true, ConfigurationSource.Embedded); + new( + NullLoggerFactory.Instance, + new ConfigurationFileSystem(fileSystem), + skipPrivateRepositories: true, + ConfigurationSource.Embedded + ); private static AssemblyConfiguration CreateConfig(params string[] privateRepoNames) { @@ -29,7 +34,8 @@ public void ConsecutiveSiblingPrivateEntries_BothRemoved() var provider = CreateProvider(fileSystem); // language=yaml - var navYaml = """ + var navYaml = + """ toc: - toc: docs-content://getting-started path_prefix: getting-started @@ -62,7 +68,8 @@ public void DeeplyNestedConsecutivePrivateEntries_BothRemoved() var provider = CreateProvider(fileSystem); // language=yaml - var navYaml = """ + var navYaml = + """ toc: - toc: docs-content://top path_prefix: top @@ -98,7 +105,8 @@ public void PrivateRepoWithChildren_EntryRemovedChildrenReindented() var provider = CreateProvider(fileSystem); // language=yaml - var navYaml = """ + var navYaml = + """ toc: - toc: docs-content://top path_prefix: top @@ -129,7 +137,8 @@ public void PublicEntriesBetweenPrivateEntries_Preserved() var provider = CreateProvider(fileSystem); // language=yaml - var navYaml = """ + var navYaml = + """ toc: - toc: docs-content://top path_prefix: top @@ -159,7 +168,8 @@ public void IslandProperty_OnPublicEntry_SurvivesPrivateRepoStripping() var provider = CreateProvider(fileSystem); // language=yaml - var navYaml = """ + var navYaml = + """ toc: - toc: public-repo://reference path_prefix: reference @@ -191,7 +201,8 @@ public void IslandProperty_OnPrivateEntry_IsRemovedWithEntry() var provider = CreateProvider(fileSystem); // language=yaml - var navYaml = """ + var navYaml = + """ toc: - toc: public-repo://reference path_prefix: reference diff --git a/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs b/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs index 34bfb84791..c5ce80a069 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs @@ -39,8 +39,12 @@ public void CrossLinkEntry_BareRepo_InheritsDocsetRegistry() var docSet = CreateDocSet("internal", ["other-internal-repo"]); var config = CreateConfiguration(docSet); - config.CrossLinkEntries.Should().ContainSingle() - .Which.Should().Be(new CrossLinkEntry("other-internal-repo", DocSetRegistry.Internal)); + config.CrossLinkEntries + .Should() + .ContainSingle() + .Which + .Should() + .Be(new CrossLinkEntry("other-internal-repo", DocSetRegistry.Internal)); } [Fact] @@ -72,8 +76,7 @@ public void CrossLinkEntry_PublicDocset_InternalPrefix_ExcludesInvalidEntry() var config = CreateConfiguration(docSet); // Public docsets cannot link to internal; the invalid entry is excluded - config.CrossLinkEntries.Should().ContainSingle() - .Which.Should().Be(new CrossLinkEntry("elasticsearch", DocSetRegistry.Public)); + config.CrossLinkEntries.Should().ContainSingle().Which.Should().Be(new CrossLinkEntry("elasticsearch", DocSetRegistry.Public)); } [Fact] @@ -102,19 +105,13 @@ private static ConfigurationFile CreateConfiguration(DocumentationSetFile docSet var collector = new DiagnosticsCollector([]); var root = Paths.WorkingDirectoryRoot.FullName; var configFilePath = Path.Join(root, "docs", "_docset.yml"); - var fileSystem = new MockFileSystem(new Dictionary - { - { configFilePath, new MockFileData("") } - }, root); + var fileSystem = new MockFileSystem(new Dictionary { { configFilePath, new MockFileData("") } }, root); var configPath = fileSystem.FileInfo.New(configFilePath); var docsDir = fileSystem.DirectoryInfo.New(Path.Join(root, "docs")); var context = new MockDocumentationSetContext(collector, fileSystem, configPath, docsDir); - var versionsConfig = new VersionsConfiguration - { - VersioningSystems = new Dictionary() - }; + var versionsConfig = new VersionsConfiguration { VersioningSystems = new Dictionary() }; var productsConfig = new ProductsConfiguration { Products = new Dictionary().ToFrozenDictionary(), @@ -129,13 +126,15 @@ private sealed class MockDocumentationSetContext( IDiagnosticsCollector collector, IFileSystem fileSystem, IFileInfo configurationPath, - IDirectoryInfo documentationSourceDirectory) - : IDocumentationSetContext + IDirectoryInfo documentationSourceDirectory + ) : IDocumentationSetContext { public IDiagnosticsCollector Collector => collector; public IDocumentationFileSystem ReadFileSystem { get; } = DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName); public DocumentationWriteFileSystem WriteFileSystem { get; } = new DocumentationWriteFileSystem( - fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: fileSystem); + fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), + inner: fileSystem + ); public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); public IFileInfo ConfigurationPath => configurationPath; public BuildType BuildType => BuildType.Isolated; diff --git a/tests/Elastic.Documentation.Configuration.Tests/DocumentInferrerServiceTests.cs b/tests/Elastic.Documentation.Configuration.Tests/DocumentInferrerServiceTests.cs index dd4268bb79..0d5d5d8c0a 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/DocumentInferrerServiceTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/DocumentInferrerServiceTests.cs @@ -22,14 +22,10 @@ private static VersionsConfiguration CreateVersionsConfiguration() foreach (var id in Enum.GetValues()) { // Create a versionless system for "All" type IDs - var isVersionless = id is VersioningSystemId.All or VersioningSystemId.Serverless - or VersioningSystemId.Ess or VersioningSystemId.Ech - or VersioningSystemId.ElasticsearchProject or VersioningSystemId.ObservabilityProject - or VersioningSystemId.SecurityProject; + var isVersionless = + id is VersioningSystemId.All or VersioningSystemId.Serverless or VersioningSystemId.Ess or VersioningSystemId.Ech or VersioningSystemId.ElasticsearchProject or VersioningSystemId.ObservabilityProject or VersioningSystemId.SecurityProject; - var version = isVersionless - ? new SemVersion(VersioningSystem.VersionlessSentinel, 0, 0) - : new SemVersion(9, 2, 0); + var version = isVersionless ? new SemVersion(VersioningSystem.VersionlessSentinel, 0, 0) : new SemVersion(9, 2, 0); versioningSystems[id] = new VersioningSystem { @@ -128,7 +124,8 @@ public void InferForMarkdownWithDirectRepositoryMatchReturnsProduct() mappedPages: null, docsetProducts: [], frontmatterProducts: null, - applicableTo: null); + applicableTo: null + ); result.Product.Should().NotBeNull(); result.Product.Id.Should().Be("elasticsearch"); @@ -151,7 +148,8 @@ public void InferForMarkdownWithProductRepositoryReturnsProductByRepositoryField mappedPages: null, docsetProducts: [], frontmatterProducts: null, - applicableTo: null); + applicableTo: null + ); result.Product.Should().NotBeNull(); result.Product.Id.Should().Be("apm-agent-java"); @@ -174,7 +172,8 @@ public void InferForMarkdownWithLegacyMappedPagesReturnsProductFromLegacyMapping mappedPages: mappedPages, docsetProducts: [], frontmatterProducts: null, - applicableTo: null); + applicableTo: null + ); result.Product.Should().NotBeNull(); result.Product.Id.Should().Be("elasticsearch"); @@ -199,7 +198,8 @@ public void InferForMarkdownWithProductApplicabilityReturnsProductFromApplicabil mappedPages: null, docsetProducts: [], frontmatterProducts: null, - applicableTo: applicableTo); + applicableTo: applicableTo + ); result.Product.Should().NotBeNull(); result.Product.Id.Should().Be("curator"); @@ -225,7 +225,8 @@ public void InferForMarkdownLegacyMappingTakesPriorityOverApplicability() mappedPages: mappedPages, docsetProducts: [], frontmatterProducts: null, - applicableTo: applicableTo); + applicableTo: applicableTo + ); // Legacy mapping should take priority result.Product.Should().NotBeNull(); @@ -251,7 +252,8 @@ public void InferForMarkdownApplicabilityTakesPriorityOverRepository() mappedPages: null, docsetProducts: [], frontmatterProducts: null, - applicableTo: applicableTo); + applicableTo: applicableTo + ); // Applicability should take priority over repository match result.Product.Should().NotBeNull(); @@ -278,7 +280,8 @@ public void InferForMarkdownCollectsAllRelatedProducts() mappedPages: mappedPages, docsetProducts: [], frontmatterProducts: null, - applicableTo: applicableTo); + applicableTo: applicableTo + ); // Should collect all products: elasticsearch (from legacy), curator (from applicability), kibana (from repo) result.RelatedProducts.Should().HaveCount(3); @@ -303,7 +306,8 @@ public void InferForMarkdownIncludesFrontmatterProductsInRelatedProducts() mappedPages: null, docsetProducts: [], frontmatterProducts: frontmatterProducts, - applicableTo: null); + applicableTo: null + ); result.RelatedProducts.Should().HaveCount(2); result.RelatedProducts.Select(p => p.Id).Should().Contain("elasticsearch"); @@ -327,7 +331,8 @@ public void InferForMarkdownMergesDocsetAndFrontmatterProducts() mappedPages: null, docsetProducts: docsetProducts, frontmatterProducts: frontmatterProducts, - applicableTo: null); + applicableTo: null + ); // Should merge both docset and frontmatter products result.RelatedProducts.Should().HaveCount(2); @@ -351,7 +356,8 @@ public void InferForMarkdownIncludesDocsetProductsWhenNoFrontmatterProducts() mappedPages: null, docsetProducts: docsetProducts, frontmatterProducts: null, - applicableTo: null); + applicableTo: null + ); // Should include docset products even when no frontmatter products result.RelatedProducts.Should().HaveCount(1); @@ -372,7 +378,8 @@ public void InferForMarkdownWithUnknownRepositoryReturnsNullProduct() mappedPages: null, docsetProducts: [], frontmatterProducts: null, - applicableTo: null); + applicableTo: null + ); result.Product.Should().BeNull(); result.Repository.Should().Be("unknown-repo"); @@ -410,7 +417,8 @@ public void InferForMarkdownWithVersionlessProductReturnsNullVersion() mappedPages: null, docsetProducts: [], frontmatterProducts: null, - applicableTo: null); + applicableTo: null + ); result.Product.Should().NotBeNull(); result.Product.Id.Should().Be("serverless-es"); diff --git a/tests/Elastic.Documentation.Configuration.Tests/DocumentationPathsResolverTests.cs b/tests/Elastic.Documentation.Configuration.Tests/DocumentationPathsResolverTests.cs index 1f67f78ff1..1d1e95eee0 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/DocumentationPathsResolverTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/DocumentationPathsResolverTests.cs @@ -25,8 +25,7 @@ public class DocumentationPathsResolverTests /// Normalises a Unix-style path through the mock filesystem so that assertions work on /// Windows, where MockFileSystem converts /repoC:\repo. /// - private static string P(MockFileSystem fs, string unixPath) => - fs.DirectoryInfo.New(unixPath).FullName; + private static string P(MockFileSystem fs, string unixPath) => fs.DirectoryInfo.New(unixPath).FullName; /// /// Builds a minimal regular-repo filesystem: @@ -38,20 +37,26 @@ private static MockFileSystem RegularRepo( string docsRelative = "docs", string branch = "main", string sha = "abc1234", - string remote = "elastic/test-repo") + string remote = "elastic/test-repo" + ) { var fs = new MockFileSystem(); var docsPath = $"{repoRoot}/{docsRelative}"; fs.AddDirectory($"{repoRoot}/.git"); fs.AddFile($"{repoRoot}/.git/HEAD", new MockFileData($"ref: refs/heads/{branch}\n")); fs.AddFile($"{repoRoot}/.git/refs/heads/{branch}", new MockFileData($"{sha}\n")); - fs.AddFile($"{repoRoot}/.git/config", new MockFileData($""" + fs.AddFile( + $"{repoRoot}/.git/config", + new MockFileData( + $""" [remote "origin"] url = https://github.com/{remote}.git [branch "{branch}"] remote = origin merge = refs/heads/{branch} - """)); + """ + ) + ); fs.AddFile($"{docsPath}/docset.yml", new MockFileData("toc: []\n")); return fs; } @@ -66,7 +71,8 @@ private static MockFileSystem WorktreeWithCommondir( string branch = "topic", string sha = "fedcba987654", string remote = "elastic/worktree-repo", - string docsRelative = "docs") + string docsRelative = "docs" + ) { var fs = new MockFileSystem(); var worktreeGitDir = $"{mainRoot}/.git/worktrees/wt"; @@ -84,13 +90,18 @@ private static MockFileSystem WorktreeWithCommondir( fs.AddDirectory(mainGitDir); fs.AddFile($"{mainGitDir}/HEAD", new MockFileData($"ref: refs/heads/{branch}\n")); fs.AddFile($"{mainGitDir}/refs/heads/{branch}", new MockFileData($"{sha}\n")); - fs.AddFile($"{mainGitDir}/config", new MockFileData($""" + fs.AddFile( + $"{mainGitDir}/config", + new MockFileData( + $""" [remote "origin"] url = https://github.com/{remote}.git [branch "{branch}"] remote = origin merge = refs/heads/{branch} - """)); + """ + ) + ); fs.AddFile($"{docsPath}/docset.yml", new MockFileData("toc: []\n")); return fs; @@ -129,18 +140,22 @@ public void PathRepoRoot_And_PathDocsSubfolder_ResolveIdenticalCheckoutAndSource { var fs = RegularRepo(); - var fromRoot = DocumentationPathsResolver.Resolve( - fs.DirectoryInfo.New("/repo"), - new DocumentationScopeOptions { Inner = fs }, fs); + var fromRoot = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/repo"), new DocumentationScopeOptions { Inner = fs }, fs); var fromDocs = DocumentationPathsResolver.Resolve( fs.DirectoryInfo.New("/repo/docs"), - new DocumentationScopeOptions { Inner = fs }, fs); - - fromRoot.CheckoutDirectory.FullName.Should().Be(fromDocs.CheckoutDirectory.FullName, - "--path /repo and --path /repo/docs must converge on the same checkout"); - fromRoot.SourceDirectory.FullName.Should().Be(fromDocs.SourceDirectory.FullName, - "--path /repo and --path /repo/docs must converge on the same source"); + new DocumentationScopeOptions { Inner = fs }, + fs + ); + + fromRoot.CheckoutDirectory + .FullName + .Should() + .Be(fromDocs.CheckoutDirectory.FullName, "--path /repo and --path /repo/docs must converge on the same checkout"); + fromRoot.SourceDirectory + .FullName + .Should() + .Be(fromDocs.SourceDirectory.FullName, "--path /repo and --path /repo/docs must converge on the same source"); } [Fact] @@ -157,8 +172,10 @@ public void AnchorTwoLevelsBelowInvocationRoot_StillResolvesCheckoutAtGitRoot() var paths = DocumentationPathsResolver.Resolve(invocation, new DocumentationScopeOptions { Inner = fs }, fs); paths.SourceDirectory.FullName.Should().Be(P(fs, "/repo/docs/resilience-team")); - paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/repo"), - "the anchor's depth below the invocation root should widen the git-root search, not require --git-dir"); + paths.CheckoutDirectory + .FullName + .Should() + .Be(P(fs, "/repo"), "the anchor's depth below the invocation root should widen the git-root search, not require --git-dir"); paths.Git.IsAvailable.Should().BeTrue(); } @@ -178,8 +195,13 @@ public void AnchorAtInvocationRoot_UnrelatedAncestorGit_StillOutOfReach() var paths = DocumentationPathsResolver.Resolve(invocation, opts, fs); - paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/parent-repo/checkout/docs"), - "depth-widening is relative to the invocation, so an ancestor repo's .git outside the invocation must not be adopted"); + paths.CheckoutDirectory + .FullName + .Should() + .Be( + P(fs, "/parent-repo/checkout/docs"), + "depth-widening is relative to the invocation, so an ancestor repo's .git outside the invocation must not be adopted" + ); } [Fact] @@ -203,12 +225,9 @@ public void RegularRepo_GitDirectories_ContainsOneEntry() { var fs = RegularRepo(); - var paths = DocumentationPathsResolver.Resolve( - fs.DirectoryInfo.New("/repo"), - new DocumentationScopeOptions { Inner = fs }, fs); + var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/repo"), new DocumentationScopeOptions { Inner = fs }, fs); - paths.GitDirectories.Should().ContainSingle() - .Which.Should().Be(P(fs, "/repo/.git")); + paths.GitDirectories.Should().ContainSingle().Which.Should().Be(P(fs, "/repo/.git")); } [Fact] @@ -216,9 +235,7 @@ public void RegularRepo_GitInfo_IsResolved() { var fs = RegularRepo(branch: "my-branch", sha: "deadbeef1234", remote: "elastic/my-repo"); - var paths = DocumentationPathsResolver.Resolve( - fs.DirectoryInfo.New("/repo"), - new DocumentationScopeOptions { Inner = fs }, fs); + var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/repo"), new DocumentationScopeOptions { Inner = fs }, fs); paths.Git.IsAvailable.Should().BeTrue(); paths.Git.Branch.Should().Be("my-branch"); @@ -235,9 +252,7 @@ public void Worktree_CheckoutIsWorktreeRoot_NotMainRepo() { var fs = WorktreeWithCommondir(); - var paths = DocumentationPathsResolver.Resolve( - fs.DirectoryInfo.New("/worktree"), - new DocumentationScopeOptions { Inner = fs }, fs); + var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/worktree"), new DocumentationScopeOptions { Inner = fs }, fs); paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/worktree")); } @@ -247,15 +262,13 @@ public void Worktree_GitDirectories_ContainsPointerAndMainGit() { var fs = WorktreeWithCommondir(); - var paths = DocumentationPathsResolver.Resolve( - fs.DirectoryInfo.New("/worktree"), - new DocumentationScopeOptions { Inner = fs }, fs); + var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/worktree"), new DocumentationScopeOptions { Inner = fs }, fs); paths.GitDirectories.Should().HaveCount(2); - paths.GitDirectories.Should().Contain(P(fs, "/worktree/.git"), - "pointer file path must be in scope so the .git file is readable"); - paths.GitDirectories.Should().Contain(P(fs, "/main/.git"), - "resolved commondir target must be included so config/HEAD are readable"); + paths.GitDirectories.Should().Contain(P(fs, "/worktree/.git"), "pointer file path must be in scope so the .git file is readable"); + paths.GitDirectories + .Should() + .Contain(P(fs, "/main/.git"), "resolved commondir target must be included so config/HEAD are readable"); } [Fact] @@ -263,9 +276,7 @@ public void Worktree_GitInfo_ResolvedFromMainDotGit() { var fs = WorktreeWithCommondir(branch: "topic", sha: "fedcba987654", remote: "elastic/worktree-repo"); - var paths = DocumentationPathsResolver.Resolve( - fs.DirectoryInfo.New("/worktree"), - new DocumentationScopeOptions { Inner = fs }, fs); + var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/worktree"), new DocumentationScopeOptions { Inner = fs }, fs); paths.Git.IsAvailable.Should().BeTrue(); paths.Git.Branch.Should().Be("topic"); @@ -280,14 +291,23 @@ public void Worktree_InvocationAtDocsSubfolder_ResolvesIdenticallyToWorktreeRoot var fromWorktreeRoot = DocumentationPathsResolver.Resolve( fs.DirectoryInfo.New("/worktree"), - new DocumentationScopeOptions { Inner = fs }, fs); + new DocumentationScopeOptions { Inner = fs }, + fs + ); var fromDocs = DocumentationPathsResolver.Resolve( fs.DirectoryInfo.New("/worktree/docs"), - new DocumentationScopeOptions { Inner = fs }, fs); - - fromWorktreeRoot.CheckoutDirectory.FullName.Should().Be(fromDocs.CheckoutDirectory.FullName, - "worktree: --path /worktree and --path /worktree/docs must resolve to the same checkout"); + new DocumentationScopeOptions { Inner = fs }, + fs + ); + + fromWorktreeRoot.CheckoutDirectory + .FullName + .Should() + .Be( + fromDocs.CheckoutDirectory.FullName, + "worktree: --path /worktree and --path /worktree/docs must resolve to the same checkout" + ); } // ----------------------------------------------------------------------- @@ -302,25 +322,25 @@ public void ExplicitGitDir_CheckoutIsGitDirParent() fs.AddDirectory("/repo/.git"); fs.AddFile("/repo/.git/HEAD", new MockFileData("ref: refs/heads/main\n")); fs.AddFile("/repo/.git/refs/heads/main", new MockFileData("cafe1234\n")); - fs.AddFile("/repo/.git/config", new MockFileData(""" + fs.AddFile( + "/repo/.git/config", + new MockFileData( + """ [remote "origin"] url = https://github.com/elastic/override-test.git [branch "main"] remote = origin merge = refs/heads/main - """)); + """ + ) + ); fs.AddFile("/project/docs/docset.yml", new MockFileData("toc: []\n")); - var opts = new DocumentationScopeOptions - { - Inner = fs, - GitDir = "/repo/.git" - }; + var opts = new DocumentationScopeOptions { Inner = fs, GitDir = "/repo/.git" }; var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/project/docs"), opts, fs); - paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/repo"), - "--git-dir /repo/.git → checkout = /repo/.git.Parent = /repo"); + paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/repo"), "--git-dir /repo/.git → checkout = /repo/.git.Parent = /repo"); } [Fact] @@ -330,20 +350,21 @@ public void ExplicitGitDir_GitInfo_ResolvedFromOverriddenGitDir() fs.AddDirectory("/repo/.git"); fs.AddFile("/repo/.git/HEAD", new MockFileData("ref: refs/heads/main\n")); fs.AddFile("/repo/.git/refs/heads/main", new MockFileData("aabbcc99\n")); - fs.AddFile("/repo/.git/config", new MockFileData(""" + fs.AddFile( + "/repo/.git/config", + new MockFileData( + """ [remote "origin"] url = https://github.com/elastic/override-repo.git [branch "main"] remote = origin merge = refs/heads/main - """)); + """ + ) + ); fs.AddFile("/project/docs/docset.yml", new MockFileData("toc: []\n")); - var opts = new DocumentationScopeOptions - { - Inner = fs, - GitDir = "/repo/.git" - }; + var opts = new DocumentationScopeOptions { Inner = fs, GitDir = "/repo/.git" }; var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/project/docs"), opts, fs); @@ -366,8 +387,7 @@ public void MockFsWithoutGit_DoesNotThrow_CheckoutFallsBackToSource() var opts = new DocumentationScopeOptions { Inner = fs, Git = GitCheckoutInformation.Unavailable }; var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/repo/docs"), opts, fs); - paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/repo/docs"), - "mock FS fallback: no .git → checkout = source directory"); + paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/repo/docs"), "mock FS fallback: no .git → checkout = source directory"); paths.GitDirectories.Should().BeEmpty(); } @@ -411,19 +431,17 @@ public void Output_InvocationAtDocsSubfolder_StillAnchorsToCheckout() var fromRoot = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/repo"), opts, fs); var fromDocs = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/repo/docs"), opts, fs); - fromRoot.OutputDirectory.FullName.Should().Be(fromDocs.OutputDirectory.FullName, - "--path /repo and --path /repo/docs must produce the same default output directory"); + fromRoot.OutputDirectory + .FullName + .Should() + .Be(fromDocs.OutputDirectory.FullName, "--path /repo and --path /repo/docs must produce the same default output directory"); } [Fact] public void Output_ExplicitOverride_IsRespected() { var fs = RegularRepo(); - var opts = new DocumentationScopeOptions - { - Inner = fs, - Output = "/custom/output" - }; + var opts = new DocumentationScopeOptions { Inner = fs, Output = "/custom/output" }; var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/repo"), opts, fs); @@ -458,13 +476,15 @@ public void NoDocset_Throws_DocumentationPathException() var fs = new MockFileSystem(); fs.AddDirectory("/empty"); - var act = () => DocumentationPathsResolver.Resolve( - fs.DirectoryInfo.New("/empty"), - new DocumentationScopeOptions { Inner = fs, Git = GitCheckoutInformation.Unavailable }, - fs); + var act = + () => + DocumentationPathsResolver.Resolve( + fs.DirectoryInfo.New("/empty"), + new DocumentationScopeOptions { Inner = fs, Git = GitCheckoutInformation.Unavailable }, + fs + ); - act.Should().Throw() - .WithMessage("*docset.yml*"); + act.Should().Throw().WithMessage("*docset.yml*"); } // ----------------------------------------------------------------------- diff --git a/tests/Elastic.Documentation.Configuration.Tests/DocumentationSetFileTests.cs b/tests/Elastic.Documentation.Configuration.Tests/DocumentationSetFileTests.cs index 74e22604fd..d48fdbc9c5 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/DocumentationSetFileTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/DocumentationSetFileTests.cs @@ -15,14 +15,14 @@ namespace Elastic.Documentation.Configuration.Tests; public class DocumentationSetFileTests { // Tests use direct deserialization to test YAML parsing without TOC loading/resolution - private DocumentationSetFile Deserialize(string yaml) => - ConfigurationFileProvider.Deserializer.Deserialize(yaml); + private DocumentationSetFile Deserialize(string yaml) => ConfigurationFileProvider.Deserializer.Deserialize(yaml); [Fact] public void DeserializesBasicProperties() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' max_toc_depth: 3 dev_docs: true @@ -39,19 +39,16 @@ public void DeserializesBasicProperties() result.Project.Should().Be("test-project"); result.MaxTocDepth.Should().Be(3); result.DevDocs.Should().BeTrue(); - result.CrossLinks.Should().HaveCount(2) - .And.Contain("docs-content") - .And.Contain("other-docs"); - result.Exclude.Should().HaveCount(2) - .And.Contain("_*.md") - .And.Contain("*.tmp"); + result.CrossLinks.Should().HaveCount(2).And.Contain("docs-content").And.Contain("other-docs"); + result.Exclude.Should().HaveCount(2).And.Contain("_*.md").And.Contain("*.tmp"); } [Fact] public void DeserializesSubstitutions() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' subs: stack: Elastic Stack @@ -61,8 +58,7 @@ public void DeserializesSubstitutions() var result = Deserialize(yaml); - result.Subs.Should().HaveCount(3) - .And.ContainKey("stack").WhoseValue.Should().Be("Elastic Stack"); + result.Subs.Should().HaveCount(3).And.ContainKey("stack").WhoseValue.Should().Be("Elastic Stack"); result.Subs.Should().ContainKey("ecloud").WhoseValue.Should().Be("Elastic Cloud"); result.Subs.Should().ContainKey("dbuild").WhoseValue.Should().Be("docs-builder"); } @@ -71,7 +67,8 @@ public void DeserializesSubstitutions() public void DeserializesFeatures() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' features: primary-nav: false @@ -87,7 +84,8 @@ public void DeserializesFeatures() public void DeserializesApiConfiguration() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' api: elasticsearch: @@ -109,7 +107,8 @@ public void DeserializesApiConfiguration() public void DeserializesFileReference() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: index.md @@ -119,17 +118,23 @@ public void DeserializesFileReference() var result = Deserialize(yaml); result.TableOfContents.Should().HaveCount(2); - result.TableOfContents.ElementAt(0).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("index.md"); - result.TableOfContents.ElementAt(1).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("getting-started.md"); + result.TableOfContents.ElementAt(0).Should().BeOfType().Which.PathRelativeToDocumentationSet.Should().Be("index.md"); + result.TableOfContents + .ElementAt(1) + .Should() + .BeOfType() + .Which + .PathRelativeToDocumentationSet + .Should() + .Be("getting-started.md"); } [Fact] public void DeserializesHiddenFileReference() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: index.md @@ -140,21 +145,18 @@ public void DeserializesHiddenFileReference() var result = Deserialize(yaml); result.TableOfContents.Should().HaveCount(3); - result.TableOfContents.ElementAt(0).Should().BeOfType() - .Which.Hidden.Should().BeFalse(); - result.TableOfContents.ElementAt(1).Should().BeOfType() - .Which.Hidden.Should().BeTrue(); - result.TableOfContents.ElementAt(1).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("404.md"); - result.TableOfContents.ElementAt(2).Should().BeOfType() - .Which.Hidden.Should().BeTrue(); + result.TableOfContents.ElementAt(0).Should().BeOfType().Which.Hidden.Should().BeFalse(); + result.TableOfContents.ElementAt(1).Should().BeOfType().Which.Hidden.Should().BeTrue(); + result.TableOfContents.ElementAt(1).Should().BeOfType().Which.PathRelativeToDocumentationSet.Should().Be("404.md"); + result.TableOfContents.ElementAt(2).Should().BeOfType().Which.Hidden.Should().BeTrue(); } [Fact] public void DeserializesFolderReference() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: contribute @@ -169,17 +171,16 @@ public void DeserializesFolderReference() var folder = result.TableOfContents.ElementAt(0).Should().BeOfType().Subject; folder.PathRelativeToDocumentationSet.Should().Be("contribute"); folder.Children.Should().HaveCount(2); - folder.Children.ElementAt(0).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("index.md"); - folder.Children.ElementAt(1).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("locally.md"); + folder.Children.ElementAt(0).Should().BeOfType().Which.PathRelativeToDocumentationSet.Should().Be("index.md"); + folder.Children.ElementAt(1).Should().BeOfType().Which.PathRelativeToDocumentationSet.Should().Be("locally.md"); } [Fact] public void DeserializesTocReference() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: index.md @@ -190,15 +191,22 @@ public void DeserializesTocReference() result.TableOfContents.Should().HaveCount(2); result.TableOfContents.ElementAt(0).Should().BeOfType(); - result.TableOfContents.ElementAt(1).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("development"); + result.TableOfContents + .ElementAt(1) + .Should() + .BeOfType() + .Which + .PathRelativeToDocumentationSet + .Should() + .Be("development"); } [Fact] public void DeserializesCrossLinkReference() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: index.md @@ -222,7 +230,8 @@ public void DeserializesCrossLinkReference() public void DeserializesNestedStructure() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: configure @@ -242,25 +251,22 @@ public void DeserializesNestedStructure() topFolder.PathRelativeToDocumentationSet.Should().Be("configure"); topFolder.Children.Should().HaveCount(2); - topFolder.Children.ElementAt(0).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("index.md"); + topFolder.Children.ElementAt(0).Should().BeOfType().Which.PathRelativeToDocumentationSet.Should().Be("index.md"); var nestedFolder = topFolder.Children.ElementAt(1).Should().BeOfType().Subject; nestedFolder.PathRelativeToDocumentationSet.Should().Be("site"); nestedFolder.Children.Should().HaveCount(3); - nestedFolder.Children.ElementAt(0).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("index.md"); - nestedFolder.Children.ElementAt(1).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("content.md"); - nestedFolder.Children.ElementAt(2).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("navigation.md"); + nestedFolder.Children.ElementAt(0).Should().BeOfType().Which.PathRelativeToDocumentationSet.Should().Be("index.md"); + nestedFolder.Children.ElementAt(1).Should().BeOfType().Which.PathRelativeToDocumentationSet.Should().Be("content.md"); + nestedFolder.Children.ElementAt(2).Should().BeOfType().Which.PathRelativeToDocumentationSet.Should().Be("navigation.md"); } [Fact] public void DeserializesCompleteDocsetYaml() { // language=yaml - var yaml = """ + var yaml = + """ project: 'doc-builder' max_toc_depth: 2 dev_docs: true @@ -373,7 +379,8 @@ public void DeserializesCompleteDocsetYaml() public void DeserializesFileWithChildren() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: guide.md @@ -389,19 +396,17 @@ public void DeserializesFileWithChildren() var guide = result.TableOfContents.ElementAt(0).Should().BeOfType().Subject; guide.PathRelativeToDocumentationSet.Should().Be("guide.md"); guide.Children.Should().HaveCount(3); - guide.Children.ElementAt(0).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("chapter1.md"); - guide.Children.ElementAt(1).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("chapter2.md"); - guide.Children.ElementAt(2).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("chapter3.md"); + guide.Children.ElementAt(0).Should().BeOfType().Which.PathRelativeToDocumentationSet.Should().Be("chapter1.md"); + guide.Children.ElementAt(1).Should().BeOfType().Which.PathRelativeToDocumentationSet.Should().Be("chapter2.md"); + guide.Children.ElementAt(2).Should().BeOfType().Which.PathRelativeToDocumentationSet.Should().Be("chapter3.md"); } [Fact] public void DeserializesFileWithNestedPathsAsChildren() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: api/guide.md @@ -416,17 +421,16 @@ public void DeserializesFileWithNestedPathsAsChildren() var guide = result.TableOfContents.ElementAt(0).Should().BeOfType().Subject; guide.PathRelativeToDocumentationSet.Should().Be("api/guide.md"); guide.Children.Should().HaveCount(2); - guide.Children.ElementAt(0).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("api/section1.md"); - guide.Children.ElementAt(1).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("api/section2.md"); + guide.Children.ElementAt(0).Should().BeOfType().Which.PathRelativeToDocumentationSet.Should().Be("api/section1.md"); + guide.Children.ElementAt(1).Should().BeOfType().Which.PathRelativeToDocumentationSet.Should().Be("api/section2.md"); } [Fact] public void DeserializesDefaultValues() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: index.md @@ -461,7 +465,8 @@ public void DeserializesEmptyToc() public void DeserializesCrossLinkWithoutTitle() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: index.md @@ -481,7 +486,8 @@ public void DeserializesCrossLinkWithoutTitle() public void DeserializesMixedHiddenAndVisibleItems() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: index.md @@ -493,21 +499,18 @@ public void DeserializesMixedHiddenAndVisibleItems() var result = Deserialize(yaml); result.TableOfContents.Should().HaveCount(4); - result.TableOfContents.ElementAt(0).Should().BeOfType() - .Which.Hidden.Should().BeFalse(); - result.TableOfContents.ElementAt(1).Should().BeOfType() - .Which.Hidden.Should().BeTrue(); - result.TableOfContents.ElementAt(2).Should().BeOfType() - .Which.Hidden.Should().BeFalse(); - result.TableOfContents.ElementAt(3).Should().BeOfType() - .Which.Hidden.Should().BeTrue(); + result.TableOfContents.ElementAt(0).Should().BeOfType().Which.Hidden.Should().BeFalse(); + result.TableOfContents.ElementAt(1).Should().BeOfType().Which.Hidden.Should().BeTrue(); + result.TableOfContents.ElementAt(2).Should().BeOfType().Which.Hidden.Should().BeFalse(); + result.TableOfContents.ElementAt(3).Should().BeOfType().Which.Hidden.Should().BeTrue(); } [Fact] public void DeserializesDeeplyNestedFileWithChildren() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: guide.md @@ -534,7 +537,8 @@ public void DeserializesDeeplyNestedFileWithChildren() public void DeserializesMultipleExcludePatterns() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' exclude: - '_*.md' @@ -548,15 +552,15 @@ public void DeserializesMultipleExcludePatterns() var result = Deserialize(yaml); - result.Exclude.Should().HaveCount(5) - .And.ContainInOrder("_*.md", "*.tmp", "*.draft", ".DS_Store", "node_modules/**"); + result.Exclude.Should().HaveCount(5).And.ContainInOrder("_*.md", "*.tmp", "*.draft", ".DS_Store", "node_modules/**"); } [Fact] public void DeserializesMultipleCrossLinks() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' cross_links: - elasticsearch @@ -569,15 +573,15 @@ public void DeserializesMultipleCrossLinks() var result = Deserialize(yaml); - result.CrossLinks.Should().HaveCount(4) - .And.ContainInOrder("elasticsearch", "kibana", "docs-content", "cloud"); + result.CrossLinks.Should().HaveCount(4).And.ContainInOrder("elasticsearch", "kibana", "docs-content", "cloud"); } [Fact] public void DeserializesFolderWithMixedChildren() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: api @@ -606,7 +610,8 @@ public void LoadAndResolveResolvesIsolatedTocReferences() // Main docset.yml // language=yaml - var docsetYaml = """ + var docsetYaml = + """ project: 'test-project' toc: - file: index.md @@ -619,7 +624,8 @@ public void LoadAndResolveResolvesIsolatedTocReferences() // development/toc.yml // language=yaml - var developmentTocYaml = """ + var developmentTocYaml = + """ toc: - file: index.md - file: contributing.md @@ -630,7 +636,8 @@ public void LoadAndResolveResolvesIsolatedTocReferences() // guides/advanced/toc.yml // language=yaml - var advancedTocYaml = """ + var advancedTocYaml = + """ toc: - file: index.md - file: patterns.md @@ -649,44 +656,79 @@ public void LoadAndResolveResolvesIsolatedTocReferences() result.TableOfContents.Should().HaveCount(3); // First item: file from main docset - result.TableOfContents.ElementAt(0).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("index.md"); + result.TableOfContents.ElementAt(0).Should().BeOfType().Which.PathRelativeToDocumentationSet.Should().Be("index.md"); // Second item: development TOC (preserved as IsolatedTableOfContentsRef with resolved children) var developmentToc = result.TableOfContents.ElementAt(1).Should().BeOfType().Subject; developmentToc.PathRelativeToDocumentationSet.Should().Be("development"); developmentToc.Children.Should().HaveCount(3, "should have index, contributing file, and internals folder"); - developmentToc.Children.ElementAt(0).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("development/index.md", "TOC path should be prepended"); - - developmentToc.Children.ElementAt(1).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("development/contributing.md"); + developmentToc.Children + .ElementAt(0) + .Should() + .BeOfType() + .Which + .PathRelativeToDocumentationSet + .Should() + .Be("development/index.md", "TOC path should be prepended"); + + developmentToc.Children + .ElementAt(1) + .Should() + .BeOfType() + .Which + .PathRelativeToDocumentationSet + .Should() + .Be("development/contributing.md"); var internalsFolder = developmentToc.Children.ElementAt(2).Should().BeOfType().Subject; internalsFolder.PathRelativeToDocumentationSet.Should().Be("development/internals"); internalsFolder.Children.Should().HaveCount(1); - internalsFolder.Children.ElementAt(0).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("development/internals/architecture.md"); + internalsFolder.Children + .ElementAt(0) + .Should() + .BeOfType() + .Which + .PathRelativeToDocumentationSet + .Should() + .Be("development/internals/architecture.md"); // Third item: guides folder (preserved with its children including nested advanced TOC) var guidesFolder = result.TableOfContents.ElementAt(2).Should().BeOfType().Subject; guidesFolder.PathRelativeToDocumentationSet.Should().Be("guides"); guidesFolder.Children.Should().HaveCount(2, "should have getting-started file and advanced TOC"); - guidesFolder.Children.ElementAt(0).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("guides/getting-started.md"); + guidesFolder.Children + .ElementAt(0) + .Should() + .BeOfType() + .Which + .PathRelativeToDocumentationSet + .Should() + .Be("guides/getting-started.md"); // Advanced TOC preserved as IsolatedTableOfContentsRef within guides folder var advancedToc = guidesFolder.Children.ElementAt(1).Should().BeOfType().Subject; advancedToc.PathRelativeToDocumentationSet.Should().Be("guides/advanced"); advancedToc.Children.Should().HaveCount(2); - advancedToc.Children.ElementAt(0).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("guides/advanced/index.md"); - - advancedToc.Children.ElementAt(1).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("guides/advanced/patterns.md"); + advancedToc.Children + .ElementAt(0) + .Should() + .BeOfType() + .Which + .PathRelativeToDocumentationSet + .Should() + .Be("guides/advanced/index.md"); + + advancedToc.Children + .ElementAt(1) + .Should() + .BeOfType() + .Which + .PathRelativeToDocumentationSet + .Should() + .Be("guides/advanced/patterns.md"); } [Fact] @@ -695,7 +737,8 @@ public void LoadAndResolvePrependsParentPathsToFileReferences() var fileSystem = new MockFileSystem(); // language=yaml - var docsetYaml = """ + var docsetYaml = + """ project: 'test-project' toc: - file: guide.md @@ -734,11 +777,23 @@ public void LoadAndResolvePrependsParentPathsToFileReferences() apiFolder.PathRelativeToDocumentationSet.Should().Be("api"); apiFolder.Children.Should().HaveCount(2); - apiFolder.Children.ElementAt(0).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("api/index.md", "folder path 'api' should be prepended"); - - apiFolder.Children.ElementAt(1).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("api/reference.md", "folder path 'api' should be prepended"); + apiFolder.Children + .ElementAt(0) + .Should() + .BeOfType() + .Which + .PathRelativeToDocumentationSet + .Should() + .Be("api/index.md", "folder path 'api' should be prepended"); + + apiFolder.Children + .ElementAt(1) + .Should() + .BeOfType() + .Which + .PathRelativeToDocumentationSet + .Should() + .Be("api/reference.md", "folder path 'api' should be prepended"); } [Fact] @@ -747,7 +802,8 @@ public void LoadAndResolveSetsContextForAllItems() var fileSystem = new MockFileSystem(); // language=yaml - var docsetYaml = """ + var docsetYaml = + """ project: 'test-project' toc: - file: index.md @@ -759,7 +815,8 @@ public void LoadAndResolveSetsContextForAllItems() // development/toc.yml // language=yaml - var developmentTocYaml = """ + var developmentTocYaml = + """ toc: - file: contributing.md """; @@ -775,21 +832,18 @@ public void LoadAndResolveSetsContextForAllItems() var toc = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "C:/docs/development/toc.yml" : "/docs/development/toc.yml"; // All items from docset.yml should have context = /docs/docset.yml - result.TableOfContents.ElementAt(0).Should().BeOfType() - .Which.Context.OptionalWindowsReplace().Should().Be(docset); + result.TableOfContents.ElementAt(0).Should().BeOfType().Which.Context.OptionalWindowsReplace().Should().Be(docset); var guidesFolder = result.TableOfContents.ElementAt(1).Should().BeOfType().Subject; guidesFolder.Context.Should().Be(docset); - guidesFolder.Children.ElementAt(0).Should().BeOfType() - .Which.Context.OptionalWindowsReplace().Should().Be(docset); + guidesFolder.Children.ElementAt(0).Should().BeOfType().Which.Context.OptionalWindowsReplace().Should().Be(docset); // The TOC ref itself has context = /docs/docset.yml (where it was referenced) var developmentToc = result.TableOfContents.ElementAt(2).Should().BeOfType().Subject; developmentToc.Context.OptionalWindowsReplace().Should().Be(docset); // But children of the TOC ref should have context = /docs/development/toc.yml (where they were defined) - developmentToc.Children.ElementAt(0).Should().BeOfType() - .Which.Context.OptionalWindowsReplace().Should().Be(toc); + developmentToc.Children.ElementAt(0).Should().BeOfType().Which.Context.OptionalWindowsReplace().Should().Be(toc); } // ────────────────────────────────────────────────────────────── @@ -800,7 +854,8 @@ public void LoadAndResolveSetsContextForAllItems() public void DocsetRoot_IslandTrue_Deserializes() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' island: true toc: @@ -816,7 +871,8 @@ public void DocsetRoot_IslandTrue_Deserializes() public void DocsetRoot_NoIsland_DefaultsFalse() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: index.md @@ -831,7 +887,8 @@ public void DocsetRoot_NoIsland_DefaultsFalse() public void InlineTocEntry_IslandTrue_Deserializes() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: index.md @@ -850,7 +907,8 @@ public void InlineTocEntry_IslandTrue_Deserializes() public void InlineTocEntry_NoIsland_DefaultsFalse() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: index.md @@ -870,7 +928,8 @@ public void NestedTocYml_IslandTrue_PropagatesToRef() var fileSystem = new MockFileSystem(); // language=yaml - var docsetYaml = """ + var docsetYaml = + """ project: 'test-project' toc: - file: index.md @@ -878,7 +937,8 @@ public void NestedTocYml_IslandTrue_PropagatesToRef() """; // language=yaml - var referenceTocYaml = """ + var referenceTocYaml = + """ island: true toc: - file: index.md @@ -903,7 +963,8 @@ public void InlineTocEntry_IslandTrue_OrSemantics_BothInlineAndTocYml() var fileSystem = new MockFileSystem(); // language=yaml - var docsetWithInlineIsland = """ + var docsetWithInlineIsland = + """ project: 'test-project' toc: - file: index.md @@ -912,13 +973,15 @@ public void InlineTocEntry_IslandTrue_OrSemantics_BothInlineAndTocYml() """; // language=yaml - var referenceTocNoIsland = """ + var referenceTocNoIsland = + """ toc: - file: index.md """; // language=yaml - var docsetNoInlineIsland = """ + var docsetNoInlineIsland = + """ project: 'test-project' toc: - file: index.md @@ -926,7 +989,8 @@ public void InlineTocEntry_IslandTrue_OrSemantics_BothInlineAndTocYml() """; // language=yaml - var referenceTocWithIsland = """ + var referenceTocWithIsland = + """ island: true toc: - file: index.md @@ -962,7 +1026,8 @@ public void LoadAndResolveSetsPathRelativeToContainerCorrectly() // Main docset.yml // language=yaml - var docsetYaml = """ + var docsetYaml = + """ project: 'test-project' toc: - file: index.md @@ -974,7 +1039,8 @@ public void LoadAndResolveSetsPathRelativeToContainerCorrectly() // development/toc.yml // language=yaml - var developmentTocYaml = """ + var developmentTocYaml = + """ toc: - file: overview.md - folder: advanced @@ -985,7 +1051,8 @@ public void LoadAndResolveSetsPathRelativeToContainerCorrectly() // development/internals/toc.yml // language=yaml - var internalsTocYaml = """ + var internalsTocYaml = + """ toc: - file: architecture.md """; @@ -999,44 +1066,92 @@ public void LoadAndResolveSetsPathRelativeToContainerCorrectly() var result = DocumentationSetFile.LoadAndResolve(collector, docsetPath, new ScopedFileSystem(fileSystem, "/docs")); // Items in docset.yml: PathRelativeToContainer should equal PathRelativeToDocumentationSet - result.TableOfContents.ElementAt(0).Should().BeOfType() - .Which.PathRelativeToContainer.Should().Be("index.md", "file in root docset.yml"); + result.TableOfContents + .ElementAt(0) + .Should() + .BeOfType() + .Which + .PathRelativeToContainer + .Should() + .Be("index.md", "file in root docset.yml"); var guidesFolder = result.TableOfContents.ElementAt(1).Should().BeOfType().Subject; guidesFolder.PathRelativeToContainer.Should().Be("guides", "folder in root docset.yml"); - guidesFolder.Children.ElementAt(0).Should().BeOfType() - .Which.PathRelativeToContainer.Should().Be("guides/getting-started.md", "file's full path from container (docset.yml)"); + guidesFolder.Children + .ElementAt(0) + .Should() + .BeOfType() + .Which + .PathRelativeToContainer + .Should() + .Be("guides/getting-started.md", "file's full path from container (docset.yml)"); // Development TOC in docset.yml var developmentToc = result.TableOfContents.ElementAt(2).Should().BeOfType().Subject; developmentToc.PathRelativeToContainer.Should().Be("development", "toc ref in root docset.yml"); // Items in development/toc.yml: PathRelativeToContainer should be relative to development/ - developmentToc.Children.ElementAt(0).Should().BeOfType() - .Which.PathRelativeToContainer.Should().Be("overview.md", "file in development/toc.yml"); + developmentToc.Children + .ElementAt(0) + .Should() + .BeOfType() + .Which + .PathRelativeToContainer + .Should() + .Be("overview.md", "file in development/toc.yml"); var advancedFolder = developmentToc.Children.ElementAt(1).Should().BeOfType().Subject; advancedFolder.PathRelativeToContainer.Should().Be("advanced", "folder in development/toc.yml"); - advancedFolder.Children.ElementAt(0).Should().BeOfType() - .Which.PathRelativeToContainer.Should().Be("advanced/patterns.md", "file's full path from container (development/toc.yml)"); + advancedFolder.Children + .ElementAt(0) + .Should() + .BeOfType() + .Which + .PathRelativeToContainer + .Should() + .Be("advanced/patterns.md", "file's full path from container (development/toc.yml)"); // Internals TOC in development/toc.yml var internalsToc = developmentToc.Children.ElementAt(2).Should().BeOfType().Subject; internalsToc.PathRelativeToContainer.Should().Be("internals", "toc ref in development/toc.yml"); // Items in development/internals/toc.yml: PathRelativeToContainer should be relative to development/internals/ - internalsToc.Children.ElementAt(0).Should().BeOfType() - .Which.PathRelativeToContainer.Should().Be("architecture.md", "file in development/internals/toc.yml"); + internalsToc.Children + .ElementAt(0) + .Should() + .BeOfType() + .Which + .PathRelativeToContainer + .Should() + .Be("architecture.md", "file in development/internals/toc.yml"); // Verify PathRelativeToDocumentationSet is still correct (full paths from docset root) guidesFolder.PathRelativeToDocumentationSet.Should().Be("guides"); - guidesFolder.Children.ElementAt(0).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("guides/getting-started.md"); - - developmentToc.Children.ElementAt(0).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("development/overview.md"); - - internalsToc.Children.ElementAt(0).Should().BeOfType() - .Which.PathRelativeToDocumentationSet.Should().Be("development/internals/architecture.md"); + guidesFolder.Children + .ElementAt(0) + .Should() + .BeOfType() + .Which + .PathRelativeToDocumentationSet + .Should() + .Be("guides/getting-started.md"); + + developmentToc.Children + .ElementAt(0) + .Should() + .BeOfType() + .Which + .PathRelativeToDocumentationSet + .Should() + .Be("development/overview.md"); + + internalsToc.Children + .ElementAt(0) + .Should() + .BeOfType() + .Which + .PathRelativeToDocumentationSet + .Should() + .Be("development/internals/architecture.md"); } } diff --git a/tests/Elastic.Documentation.Configuration.Tests/EnvironmentInterpolationTests.cs b/tests/Elastic.Documentation.Configuration.Tests/EnvironmentInterpolationTests.cs index 45e6e970e8..82873fc518 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/EnvironmentInterpolationTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/EnvironmentInterpolationTests.cs @@ -103,13 +103,9 @@ private sealed class MockEnvironment : IEnvironmentVariables { private readonly Dictionary _variables = [with(StringComparer.Ordinal)]; - public string? this[string name] - { - set => _variables[name] = value; - } + public string? this[string name] { set => _variables[name] = value; } - public string? GetEnvironmentVariable(string name) => - _variables.GetValueOrDefault(name); + public string? GetEnvironmentVariable(string name) => _variables.GetValueOrDefault(name); public bool IsRunningOnCI => false; } diff --git a/tests/Elastic.Documentation.Configuration.Tests/GitCheckoutResolutionTests.cs b/tests/Elastic.Documentation.Configuration.Tests/GitCheckoutResolutionTests.cs index a95a200544..990e711c41 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/GitCheckoutResolutionTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/GitCheckoutResolutionTests.cs @@ -19,7 +19,14 @@ namespace Elastic.Documentation.Configuration.Tests; /// public class GitCheckoutResolutionTests { - private static MockFileSystem BuildFs(string root, string? branch = "main", string? sha = null, string? remote = null, bool worktree = false, string? worktreeGitDir = null) + private static MockFileSystem BuildFs( + string root, + string? branch = "main", + string? sha = null, + string? remote = null, + bool worktree = false, + string? worktreeGitDir = null + ) { var fs = new MockFileSystem(); sha ??= "abc1234def5678"; @@ -37,7 +44,10 @@ private static MockFileSystem BuildFs(string root, string? branch = "main", stri if (branch is not null) fs.AddFile($"{root}/.git/refs/heads/{branch}", new MockFileData($"{sha}\n")); // config - fs.AddFile($"{root}/.git/config", new MockFileData($""" + fs.AddFile( + $"{root}/.git/config", + new MockFileData( + $""" [core] repositoryformatversion = 0 [remote "origin"] @@ -45,7 +55,9 @@ private static MockFileSystem BuildFs(string root, string? branch = "main", stri [branch "{branch ?? "main"}"] remote = origin merge = refs/heads/{branch ?? "main"} - """)); + """ + ) + ); } else { @@ -56,7 +68,10 @@ private static MockFileSystem BuildFs(string root, string? branch = "main", stri fs.AddDirectory(realGitDir); fs.AddFile($"{realGitDir}/HEAD", new MockFileData($"ref: refs/heads/{branch}\n")); fs.AddFile($"{realGitDir}/refs/heads/{branch}", new MockFileData($"{sha}\n")); - fs.AddFile($"{realGitDir}/config", new MockFileData($""" + fs.AddFile( + $"{realGitDir}/config", + new MockFileData( + $""" [core] repositoryformatversion = 0 [remote "origin"] @@ -64,7 +79,9 @@ private static MockFileSystem BuildFs(string root, string? branch = "main", stri [branch "{branch}"] remote = origin merge = refs/heads/{branch} - """)); + """ + ) + ); } return fs; @@ -112,17 +129,25 @@ public void RegularRepo_DetachedHead_NeverReturnsRandomGuid() public void WorktreeWithAbsoluteGitDir_ResolvesViaMainRepo() { var sha = "1a2b3c4d5e6f"; - var fs = BuildFs("/worktree", branch: "my-feature", sha: sha, remote: "elastic/worktree-repo", - worktree: true, worktreeGitDir: "/main-repo/.git/worktrees/my-feature"); + var fs = BuildFs( + "/worktree", + branch: "my-feature", + sha: sha, + remote: "elastic/worktree-repo", + worktree: true, + worktreeGitDir: "/main-repo/.git/worktrees/my-feature" + ); // Scope must cover both the worktree dir and the main .git var scoped = new CheckoutsFileSystem(fs.DirectoryInfo.New("/worktree"), inner: fs); - var extended = new Nullean.ScopedFileSystem.ScopedFileSystem(fs, + var extended = new Nullean.ScopedFileSystem.ScopedFileSystem( + fs, new Nullean.ScopedFileSystem.ScopedFileSystemOptions(["/worktree", "/main-repo/.git/worktrees/my-feature"]) { AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" }, AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } - }); + } + ); var checkout = fs.DirectoryInfo.New("/worktree"); var result = GitCheckoutInformationFactory.Create(checkout, extended); @@ -142,21 +167,28 @@ public void WorktreeWithRelativeGitDir_ResolvesAgainstGitFileDirectory() fs.AddDirectory("/.git/worktrees/branch"); fs.AddFile("/.git/worktrees/branch/HEAD", new MockFileData("ref: refs/heads/feature\n")); fs.AddFile("/.git/worktrees/branch/refs/heads/feature", new MockFileData("aabbccdd\n")); - fs.AddFile("/.git/worktrees/branch/config", new MockFileData(""" + fs.AddFile( + "/.git/worktrees/branch/config", + new MockFileData( + """ [remote "origin"] url = https://github.com/elastic/relative-test.git [branch "feature"] remote = origin merge = refs/heads/feature - """)); + """ + ) + ); var checkout = fs.DirectoryInfo.New("/worktree"); - var scoped = new Nullean.ScopedFileSystem.ScopedFileSystem(fs, + var scoped = new Nullean.ScopedFileSystem.ScopedFileSystem( + fs, new Nullean.ScopedFileSystem.ScopedFileSystemOptions(["/worktree", "/.git"]) { AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" }, AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } - }); + } + ); var result = GitCheckoutInformationFactory.Create(checkout, scoped); @@ -178,21 +210,28 @@ public void WorktreeWithCommondir_ResolvesViaCommondir() fs.AddDirectory("/main/.git"); fs.AddFile("/main/.git/HEAD", new MockFileData("ref: refs/heads/topic\n")); fs.AddFile("/main/.git/refs/heads/topic", new MockFileData("fedcba987654\n")); - fs.AddFile("/main/.git/config", new MockFileData(""" + fs.AddFile( + "/main/.git/config", + new MockFileData( + """ [remote "origin"] url = https://github.com/elastic/commondir-test.git [branch "topic"] remote = origin merge = refs/heads/topic - """)); + """ + ) + ); var checkout = fs.DirectoryInfo.New("/worktree"); - var scoped = new Nullean.ScopedFileSystem.ScopedFileSystem(fs, + var scoped = new Nullean.ScopedFileSystem.ScopedFileSystem( + fs, new Nullean.ScopedFileSystem.ScopedFileSystemOptions(["/worktree", "/main/.git"]) { AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" }, AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } - }); + } + ); var result = GitCheckoutInformationFactory.Create(checkout, scoped); @@ -224,12 +263,20 @@ public void RegularRepo_PackedRefWithoutLooseRefFile_ResolvesShaFromPackedRefs() var fs = new MockFileSystem(); fs.AddDirectory("/repo/.git"); fs.AddFile("/repo/.git/HEAD", new MockFileData("ref: refs/heads/main\n")); - fs.AddFile("/repo/.git/packed-refs", new MockFileData(""" + fs.AddFile( + "/repo/.git/packed-refs", + new MockFileData( + """ # pack-refs with: peeled fully-peeled sorted deadbeef1234567890deadbeef1234567890dead refs/heads/main cafebabe0000000000cafebabe0000000000cafe refs/remotes/origin/main - """)); - fs.AddFile("/repo/.git/config", new MockFileData(""" + """ + ) + ); + fs.AddFile( + "/repo/.git/config", + new MockFileData( + """ [core] repositoryformatversion = 0 [remote "origin"] @@ -237,7 +284,9 @@ cafebabe0000000000cafebabe0000000000cafe refs/remotes/origin/main [branch "main"] remote = origin merge = refs/heads/main - """)); + """ + ) + ); var checkout = fs.DirectoryInfo.New("/repo"); var scoped = new CheckoutsFileSystem(fs.DirectoryInfo.New("/repo"), inner: fs); @@ -246,8 +295,12 @@ cafebabe0000000000cafebabe0000000000cafe refs/remotes/origin/main result.IsAvailable.Should().BeTrue(); result.Branch.Should().Be("main"); - result.Ref.Should().Be("deadbeef1234567890deadbeef1234567890dead", - "the SHA must be resolved from packed-refs, never the literal HEAD contents like 'ref: refs/heads/main'"); + result.Ref + .Should() + .Be( + "deadbeef1234567890deadbeef1234567890dead", + "the SHA must be resolved from packed-refs, never the literal HEAD contents like 'ref: refs/heads/main'" + ); } [Fact] diff --git a/tests/Elastic.Documentation.Configuration.Tests/GitConfigOriginParserTests.cs b/tests/Elastic.Documentation.Configuration.Tests/GitConfigOriginParserTests.cs index ee61af2a17..4204673b90 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/GitConfigOriginParserTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/GitConfigOriginParserTests.cs @@ -11,7 +11,8 @@ public class GitConfigOriginParserTests [Fact] public void TryGetRemoteOriginUrl_StandardConfig_ReturnsUrl() { - var yaml = """ + var yaml = + """ [core] repositoryformatversion = 0 [remote "origin"] @@ -28,7 +29,8 @@ public void TryGetRemoteOriginUrl_StandardConfig_ReturnsUrl() [Fact] public void TryGetRemoteOriginUrl_QuotedUrl_ReturnsUnquoted() { - var yaml = """ + var yaml = + """ [remote "origin"] url = "https://github.com/elastic/kibana.git" """; @@ -42,7 +44,8 @@ public void TryGetRemoteOriginUrl_QuotedUrl_ReturnsUnquoted() [Fact] public void TryGetRemoteOriginUrl_NoOrigin_ReturnsFalse() { - var yaml = """ + var yaml = + """ [remote "upstream"] url = https://github.com/elastic/kibana.git """; diff --git a/tests/Elastic.Documentation.Configuration.Tests/GitRemoteConfigurationReaderTests.cs b/tests/Elastic.Documentation.Configuration.Tests/GitRemoteConfigurationReaderTests.cs index a29db1e223..35ce4341be 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/GitRemoteConfigurationReaderTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/GitRemoteConfigurationReaderTests.cs @@ -18,7 +18,8 @@ public void TryReadOriginUrl_DotGitDirectory_ReadsConfig() new(""" [remote "origin"] url = git@github.com:elastic/kibana.git - """)); + """) + ); var ok = GitRemoteConfigurationReader.TryReadOriginUrl(fs, "/repo", out var url); @@ -30,18 +31,15 @@ public void TryReadOriginUrl_DotGitDirectory_ReadsConfig() public void TryReadOriginUrl_GitWorktreeFile_ResolvesGitDir() { var fs = new MockFileSystem(); - fs.AddFile( - "/wt/.git", - new("gitdir: /main/.git/worktrees/wt\n")); - fs.AddFile( - "/main/.git/worktrees/wt/commondir", - new("../..\n")); + fs.AddFile("/wt/.git", new("gitdir: /main/.git/worktrees/wt\n")); + fs.AddFile("/main/.git/worktrees/wt/commondir", new("../..\n")); fs.AddFile( "/main/.git/config", new(""" [remote "origin"] url = https://github.com/elastic/kibana.git - """)); + """) + ); var ok = GitRemoteConfigurationReader.TryReadOriginUrl(fs, "/wt", out var url); @@ -53,9 +51,7 @@ public void TryReadOriginUrl_GitWorktreeFile_ResolvesGitDir() public void TryReadOriginUrl_GitWorktreeFile_MissingCommondir_ReturnsFalse() { var fs = new MockFileSystem(); - fs.AddFile( - "/wt/.git", - new("gitdir: /main/.git/worktrees/wt\n")); + fs.AddFile("/wt/.git", new("gitdir: /main/.git/worktrees/wt\n")); var ok = GitRemoteConfigurationReader.TryReadOriginUrl(fs, "/wt", out var url); diff --git a/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs index 2a8cb49247..660a1bc757 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs @@ -13,7 +13,8 @@ public class PhysicalDocsetTests [Fact] public void CliReferenceRefReadsTitleOverrides() { - const string yaml = """ + const string yaml = + """ project: test toc: - cli: cli/schema.json @@ -96,7 +97,9 @@ public void PhysicalDocsetHasValidNestedStructure() var yaml = File.ReadAllText(docsetPath); var docSet = ConfigurationFileProvider.Deserializer.Deserialize(yaml); - var documentationFolder = docSet.TableOfContents.OfType().First(f => f.PathRelativeToDocumentationSet == "documentation"); + var documentationFolder = docSet.TableOfContents + .OfType() + .First(f => f.PathRelativeToDocumentationSet == "documentation"); documentationFolder.Children.Should().NotBeEmpty(); var nestedFolders = documentationFolder.Children.OfType().Select(f => f.PathRelativeToDocumentationSet).ToList(); @@ -118,7 +121,8 @@ public void PhysicalTestDocsetContainsFileReferencesWithChildren() var yaml = File.ReadAllText(docsetPath); var docSet = ConfigurationFileProvider.Deserializer.Deserialize(yaml); - var fileWithChildren = docSet.TableOfContents.OfType() + var fileWithChildren = docSet.TableOfContents + .OfType() .FirstOrDefault(f => f.PathRelativeToDocumentationSet == "cross-links.md" && f.Children.Count > 0); fileWithChildren.Should().NotBeNull(); diff --git a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/BundleLoaderFromContentTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/BundleLoaderFromContentTests.cs index 3035020c3e..64406604df 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/BundleLoaderFromContentTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/BundleLoaderFromContentTests.cs @@ -19,7 +19,9 @@ public void LoadBundlesFromContent_InlineEntries_AreLoaded() { var warnings = new List(); // language=yaml - var bundle = Bundle("9.3.0.yaml", """ + var bundle = Bundle( + "9.3.0.yaml", + """ products: - product: elasticsearch target: 9.3.0 @@ -31,7 +33,8 @@ public void LoadBundlesFromContent_InlineEntries_AreLoaded() checksum: c0ffee type: enhancement title: Sample enhancement - """); + """ + ); var bundles = _loader.LoadBundlesFromContent([bundle], warnings.Add); @@ -49,7 +52,9 @@ public void LoadBundlesFromContent_FileOnlyEntry_IsSkippedWithWarning() { var warnings = new List(); // language=yaml - var bundle = Bundle("9.3.0.yaml", """ + var bundle = Bundle( + "9.3.0.yaml", + """ products: - product: elasticsearch target: 9.3.0 @@ -57,7 +62,8 @@ public void LoadBundlesFromContent_FileOnlyEntry_IsSkippedWithWarning() - file: name: orphan.yaml checksum: deadbeef - """); + """ + ); var bundles = _loader.LoadBundlesFromContent([bundle], warnings.Add); @@ -78,8 +84,7 @@ public void LoadBundlesFromContent_InvalidYaml_IsSkippedWithWarning() var bundles = _loader.LoadBundlesFromContent([bundle], warnings.Add); bundles.Should().BeEmpty(); - warnings.Should().ContainSingle() - .Which.Should().Contain("broken.yaml"); + warnings.Should().ContainSingle().Which.Should().Contain("broken.yaml"); } [Fact] @@ -87,28 +92,33 @@ public void LoadBundlesFromContent_AmendFile_IsMergedIntoParent() { var warnings = new List(); // language=yaml - var parent = Bundle("9.3.0.yaml", """ + var parent = Bundle( + "9.3.0.yaml", + """ products: - product: elasticsearch target: 9.3.0 entries: - type: enhancement title: Base entry - """); + """ + ); // language=yaml - var amend = Bundle("9.3.0.amend-1.yaml", """ + var amend = Bundle( + "9.3.0.amend-1.yaml", + """ products: - product: elasticsearch target: 9.3.0 entries: - type: bug-fix title: Amended fix - """); + """ + ); var bundles = _loader.LoadBundlesFromContent([parent, amend], warnings.Add); bundles.Should().ContainSingle("the amend file merges into its parent"); - bundles[0].Entries.Select(e => e.Title) - .Should().BeEquivalentTo("Base entry", "Amended fix"); + bundles[0].Entries.Select(e => e.Title).Should().BeEquivalentTo("Base entry", "Amended fix"); } } diff --git a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogEntryFetcherTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogEntryFetcherTests.cs index 7d2f182786..e90c7890b9 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogEntryFetcherTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogEntryFetcherTests.cs @@ -12,7 +12,8 @@ namespace Elastic.Documentation.Configuration.Tests.ReleaseNotes; public class CdnChangelogEntryFetcherTests { // language=yaml - private const string SampleEntry = """ + private const string SampleEntry = + """ title: Sample enhancement type: enhancement products: @@ -36,14 +37,27 @@ private static (List Errors, List Warnings, Action EmitE [Fact] public async Task FetchAsync_HappyPath_ReturnsAllEntriesFromRegistry() { - var handler = new StubHandler(req => - req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) - ? Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "1-a.yaml" }, { "file": "2-b.yaml" } ] }""") - : Yaml(SampleEntry)); + var handler = new StubHandler( + req => + req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) + ? Json(/*lang=json,strict*/ + """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "1-a.yaml" }, { "file": "2-b.yaml" } ] }""" + ) + : Yaml(SampleEntry) + ); var (errors, warnings, emitError, emitWarning) = Diagnostics(); using var fetcher = CreateFetcher(handler); - var entries = await fetcher.FetchAsync(BaseUri, "elastic", "elasticsearch", "main", emitError, emitWarning, TestContext.Current.CancellationToken); + var entries = + await fetcher.FetchAsync( + BaseUri, + "elastic", + "elasticsearch", + "main", + emitError, + emitWarning, + TestContext.Current.CancellationToken + ); errors.Should().BeEmpty(); warnings.Should().BeEmpty(); @@ -51,27 +65,44 @@ public async Task FetchAsync_HappyPath_ReturnsAllEntriesFromRegistry() entries.Should().OnlyContain(e => e.Content.Contains("Sample enhancement")); // Artifact-root layout: entries and their registry live under changelog/{org}/{repo}/{branch}/... handler.RequestedPaths.Should().Contain("/changelog/elastic/elasticsearch/main/registry.json"); - handler.RequestedPaths.Should().Contain(p => p.EndsWith("/changelog/elastic/elasticsearch/main/1-a.yaml", StringComparison.Ordinal)); + handler.RequestedPaths + .Should() + .Contain(p => p.EndsWith("/changelog/elastic/elasticsearch/main/1-a.yaml", StringComparison.Ordinal)); } [Fact] public async Task FetchAsync_BranchWithSlashes_KeepsBranchSeparatorsInPath() { - var handler = new StubHandler(req => - req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) - ? Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elastic/elasticsearch/feature/foo", "bundles": [ { "file": "1-a.yaml" } ] }""") - : Yaml(SampleEntry)); + var handler = new StubHandler( + req => + req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) + ? Json(/*lang=json,strict*/ + """{ "schema_version": 1, "product": "elastic/elasticsearch/feature/foo", "bundles": [ { "file": "1-a.yaml" } ] }""" + ) + : Yaml(SampleEntry) + ); var (errors, warnings, emitError, emitWarning) = Diagnostics(); using var fetcher = CreateFetcher(handler); - var entries = await fetcher.FetchAsync(BaseUri, "elastic", "elasticsearch", "feature/foo", emitError, emitWarning, TestContext.Current.CancellationToken); + var entries = + await fetcher.FetchAsync( + BaseUri, + "elastic", + "elasticsearch", + "feature/foo", + emitError, + emitWarning, + TestContext.Current.CancellationToken + ); errors.Should().BeEmpty(); warnings.Should().BeEmpty(); entries.Select(e => e.FileName).Should().BeEquivalentTo("1-a.yaml"); // The branch's '/' stays a real path separator (not percent-encoded into one segment). handler.RequestedPaths.Should().Contain("/changelog/elastic/elasticsearch/feature/foo/registry.json"); - handler.RequestedPaths.Should().Contain(p => p.EndsWith("/changelog/elastic/elasticsearch/feature/foo/1-a.yaml", StringComparison.Ordinal)); + handler.RequestedPaths + .Should() + .Contain(p => p.EndsWith("/changelog/elastic/elasticsearch/feature/foo/1-a.yaml", StringComparison.Ordinal)); } [Theory] @@ -86,7 +117,16 @@ public async Task FetchAsync_UnsafeBranch_EmitsErrorAndDoesNotHitCdn(string bran var (errors, _, emitError, emitWarning) = Diagnostics(); using var fetcher = CreateFetcher(handler); - var entries = await fetcher.FetchAsync(BaseUri, "elastic", "elasticsearch", branch, emitError, emitWarning, TestContext.Current.CancellationToken); + var entries = + await fetcher.FetchAsync( + BaseUri, + "elastic", + "elasticsearch", + branch, + emitError, + emitWarning, + TestContext.Current.CancellationToken + ); entries.Should().BeEmpty(); errors.Should().ContainSingle().Which.Should().Contain("Invalid changelog pool"); @@ -100,7 +140,16 @@ public async Task FetchAsync_RegistryNotFound_EmitsErrorAndReturnsEmpty() var (errors, _, emitError, emitWarning) = Diagnostics(); using var fetcher = CreateFetcher(handler); - var entries = await fetcher.FetchAsync(BaseUri, "elastic", "elasticsearch", "main", emitError, emitWarning, TestContext.Current.CancellationToken); + var entries = + await fetcher.FetchAsync( + BaseUri, + "elastic", + "elasticsearch", + "main", + emitError, + emitWarning, + TestContext.Current.CancellationToken + ); entries.Should().BeEmpty(); errors.Should().ContainSingle().Which.Should().Contain("registry"); @@ -114,7 +163,9 @@ public async Task FetchAsync_EntryMissingAfterRetries_EmitsErrorAndReturnsEmpty( var handler = new StubHandler(req => { if (req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal)) - return Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "1-a.yaml" }, { "file": "2-missing.yaml" } ] }"""); + return Json(/*lang=json,strict*/ + """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "1-a.yaml" }, { "file": "2-missing.yaml" } ] }""" + ); return req.RequestUri!.AbsolutePath.EndsWith("/2-missing.yaml", StringComparison.Ordinal) ? new HttpResponseMessage(HttpStatusCode.NotFound) : Yaml(SampleEntry); @@ -122,12 +173,23 @@ public async Task FetchAsync_EntryMissingAfterRetries_EmitsErrorAndReturnsEmpty( var (errors, _, emitError, emitWarning) = Diagnostics(); using var fetcher = CreateFetcher(handler, maxAttempts: 3); - var entries = await fetcher.FetchAsync(BaseUri, "elastic", "elasticsearch", "main", emitError, emitWarning, TestContext.Current.CancellationToken); + var entries = + await fetcher.FetchAsync( + BaseUri, + "elastic", + "elasticsearch", + "main", + emitError, + emitWarning, + TestContext.Current.CancellationToken + ); entries.Should().BeEmpty(); errors.Should().ContainSingle().Which.Should().Contain("2-missing.yaml"); - handler.RequestedPaths.Count(p => p.EndsWith("/2-missing.yaml", StringComparison.Ordinal)) - .Should().Be(3, "the missing entry should be attempted up to the retry budget before failing"); + handler.RequestedPaths + .Count(p => p.EndsWith("/2-missing.yaml", StringComparison.Ordinal)) + .Should() + .Be(3, "the missing entry should be attempted up to the retry budget before failing"); } [Fact] @@ -139,17 +201,26 @@ public async Task FetchAsync_EntryRecoversAfterRetry_ReturnsEntry() { var path = req.RequestUri!.AbsolutePath; if (path.EndsWith("/registry.json", StringComparison.Ordinal)) - return Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "1-a.yaml" } ] }"""); + return Json(/*lang=json,strict*/ + """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "1-a.yaml" } ] }""" + ); if (path.EndsWith("/1-a.yaml", StringComparison.Ordinal)) - return Interlocked.Increment(ref entryAttempts) == 1 - ? new HttpResponseMessage(HttpStatusCode.NotFound) - : Yaml(SampleEntry); + return Interlocked.Increment(ref entryAttempts) == 1 ? new HttpResponseMessage(HttpStatusCode.NotFound) : Yaml(SampleEntry); return new HttpResponseMessage(HttpStatusCode.NotFound); }); var (errors, warnings, emitError, emitWarning) = Diagnostics(); using var fetcher = CreateFetcher(handler); - var entries = await fetcher.FetchAsync(BaseUri, "elastic", "elasticsearch", "main", emitError, emitWarning, TestContext.Current.CancellationToken); + var entries = + await fetcher.FetchAsync( + BaseUri, + "elastic", + "elasticsearch", + "main", + emitError, + emitWarning, + TestContext.Current.CancellationToken + ); errors.Should().BeEmpty(); warnings.Should().BeEmpty(); @@ -160,12 +231,22 @@ public async Task FetchAsync_EntryRecoversAfterRetry_ReturnsEntry() [Fact] public async Task FetchAsync_SchemaVersionTooNew_EmitsError() { - var handler = new StubHandler(_ => - Json(/*lang=json,strict*/ """{ "schema_version": 999, "product": "elasticsearch", "bundles": [] }""")); + var handler = new StubHandler( + _ => Json(/*lang=json,strict*/ """{ "schema_version": 999, "product": "elasticsearch", "bundles": [] }""") + ); var (errors, _, emitError, emitWarning) = Diagnostics(); using var fetcher = CreateFetcher(handler); - var entries = await fetcher.FetchAsync(BaseUri, "elastic", "elasticsearch", "main", emitError, emitWarning, TestContext.Current.CancellationToken); + var entries = + await fetcher.FetchAsync( + BaseUri, + "elastic", + "elasticsearch", + "main", + emitError, + emitWarning, + TestContext.Current.CancellationToken + ); entries.Should().BeEmpty(); errors.Should().ContainSingle().Which.Should().Contain("schema version"); @@ -174,14 +255,27 @@ public async Task FetchAsync_SchemaVersionTooNew_EmitsError() [Fact] public async Task FetchAsync_UnsafeFileName_EmitsWarningAndSkips() { - var handler = new StubHandler(req => - req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) - ? Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "../escape.yaml" }, { "file": "ok.yaml" } ] }""") - : Yaml(SampleEntry)); + var handler = new StubHandler( + req => + req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) + ? Json(/*lang=json,strict*/ + """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "../escape.yaml" }, { "file": "ok.yaml" } ] }""" + ) + : Yaml(SampleEntry) + ); var (errors, warnings, emitError, emitWarning) = Diagnostics(); using var fetcher = CreateFetcher(handler); - var entries = await fetcher.FetchAsync(BaseUri, "elastic", "elasticsearch", "main", emitError, emitWarning, TestContext.Current.CancellationToken); + var entries = + await fetcher.FetchAsync( + BaseUri, + "elastic", + "elasticsearch", + "main", + emitError, + emitWarning, + TestContext.Current.CancellationToken + ); errors.Should().BeEmpty(); entries.Select(e => e.FileName).Should().BeEquivalentTo("ok.yaml"); diff --git a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogFetcherTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogFetcherTests.cs index b87cb5ba64..0e65ffe4dd 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogFetcherTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogFetcherTests.cs @@ -15,7 +15,8 @@ namespace Elastic.Documentation.Configuration.Tests.ReleaseNotes; public class CdnChangelogFetcherTests { // language=yaml - private const string SampleBundle = """ + private const string SampleBundle = + """ products: - product: elasticsearch target: 9.3.0 @@ -28,8 +29,7 @@ public class CdnChangelogFetcherTests private static readonly Uri BaseUri = new("https://cdn.example"); - private static CdnChangelogFetcher CreateFetcher(StubHandler handler) => - new(NullLoggerFactory.Instance, new FileSystem(), handler); + private static CdnChangelogFetcher CreateFetcher(StubHandler handler) => new(NullLoggerFactory.Instance, new FileSystem(), handler); private static (List Errors, List Warnings, Action EmitError, Action EmitWarning) Diagnostics() { @@ -41,14 +41,19 @@ private static (List Errors, List Warnings, Action EmitE [Fact] public async Task FetchAsync_HappyPath_ReturnsBundlesFromRegistry() { - var handler = new StubHandler(req => - req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) - ? Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0" } ] }""") - : Yaml(SampleBundle)); + var handler = new StubHandler( + req => + req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) + ? Json(/*lang=json,strict*/ + """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0" } ] }""" + ) + : Yaml(SampleBundle) + ); var (errors, warnings, emitError, emitWarning) = Diagnostics(); using var fetcher = CreateFetcher(handler); - var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); + var bundles = + await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); errors.Should().BeEmpty(); warnings.Should().BeEmpty(); @@ -64,20 +69,33 @@ public async Task FetchAsync_HappyPath_ReturnsBundlesFromRegistry() [Fact] public async Task FetchAsync_WithVersion_OnlyDownloadsMatchingBundle() { - var handler = new StubHandler(req => - req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) - ? Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.4.0.yaml", "target": "9.4.0" }, { "file": "9.3.0.yaml", "target": "9.3.0" } ] }""") - : Yaml(SampleBundle)); + var handler = new StubHandler( + req => + req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) + ? Json(/*lang=json,strict*/ + """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.4.0.yaml", "target": "9.4.0" }, { "file": "9.3.0.yaml", "target": "9.3.0" } ] }""" + ) + : Yaml(SampleBundle) + ); var (errors, warnings, emitError, emitWarning) = Diagnostics(); using var fetcher = CreateFetcher(handler); - var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: "9.3.0", emitError, emitWarning, TestContext.Current.CancellationToken); + var bundles = + await fetcher.FetchAsync( + BaseUri, + "elasticsearch", + version: "9.3.0", + emitError, + emitWarning, + TestContext.Current.CancellationToken + ); errors.Should().BeEmpty(); warnings.Should().BeEmpty(); bundles.Should().ContainSingle(); - handler.RequestedPaths.Should().NotContain(p => p.EndsWith("/9.4.0.yaml", StringComparison.Ordinal), - "only the requested version should be downloaded"); + handler.RequestedPaths + .Should() + .NotContain(p => p.EndsWith("/9.4.0.yaml", StringComparison.Ordinal), "only the requested version should be downloaded"); handler.RequestedPaths.Should().Contain(p => p.EndsWith("/9.3.0.yaml", StringComparison.Ordinal)); } @@ -87,7 +105,8 @@ public async Task FetchAsync_WithVersion_DownloadsAmendCarryingParentProducts() // Amend materialized by a current docs-builder: it carries the parent's complete products, // so its registry entry has a target and matches the version on its own. // language=yaml - const string amendBundle = """ + const string amendBundle = + """ products: - product: elasticsearch target: 9.3.0 @@ -97,17 +116,30 @@ public async Task FetchAsync_WithVersion_DownloadsAmendCarryingParentProducts() - type: bug-fix title: Amended fix """; - var handler = new StubHandler(req => req.RequestUri!.AbsolutePath switch - { - var p when p.EndsWith("/registry.json", StringComparison.Ordinal) => - Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.4.0.yaml", "target": "9.4.0" }, { "file": "9.3.0.yaml", "target": "9.3.0" }, { "file": "9.3.0.amend-1.yaml", "target": "9.3.0" } ] }"""), - var p when p.EndsWith("/9.3.0.amend-1.yaml", StringComparison.Ordinal) => Yaml(amendBundle), - _ => Yaml(SampleBundle) - }); + var handler = new StubHandler( + req => + req.RequestUri!.AbsolutePath switch + { + var p when p.EndsWith("/registry.json", StringComparison.Ordinal) => + Json(/*lang=json,strict*/ + """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.4.0.yaml", "target": "9.4.0" }, { "file": "9.3.0.yaml", "target": "9.3.0" }, { "file": "9.3.0.amend-1.yaml", "target": "9.3.0" } ] }""" + ), + var p when p.EndsWith("/9.3.0.amend-1.yaml", StringComparison.Ordinal) => Yaml(amendBundle), + _ => Yaml(SampleBundle) + } + ); var (errors, warnings, emitError, emitWarning) = Diagnostics(); using var fetcher = CreateFetcher(handler); - var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: "9.3.0", emitError, emitWarning, TestContext.Current.CancellationToken); + var bundles = + await fetcher.FetchAsync( + BaseUri, + "elasticsearch", + version: "9.3.0", + emitError, + emitWarning, + TestContext.Current.CancellationToken + ); errors.Should().BeEmpty(); warnings.Should().BeEmpty(); @@ -116,8 +148,7 @@ var p when p.EndsWith("/9.3.0.amend-1.yaml", StringComparison.Ordinal) => Yaml(a bundles.Should().ContainSingle("the amend merges into its parent"); bundles[0].Version.Should().Be("9.3.0"); - bundles[0].Entries.Select(e => e.Title) - .Should().BeEquivalentTo("Sample enhancement", "Amended fix"); + bundles[0].Entries.Select(e => e.Title).Should().BeEquivalentTo("Sample enhancement", "Amended fix"); } [Fact] @@ -131,38 +162,62 @@ public async Task FetchAsync_WithVersion_DownloadsLegacyAmendWhoseParentMatches( - type: bug-fix title: Amended fix """; - var handler = new StubHandler(req => req.RequestUri!.AbsolutePath switch - { - var p when p.EndsWith("/registry.json", StringComparison.Ordinal) => - Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0" }, { "file": "9.3.0.amend-1.yaml", "target": null } ] }"""), - var p when p.EndsWith("/9.3.0.amend-1.yaml", StringComparison.Ordinal) => Yaml(amendBundle), - _ => Yaml(SampleBundle) - }); + var handler = new StubHandler( + req => + req.RequestUri!.AbsolutePath switch + { + var p when p.EndsWith("/registry.json", StringComparison.Ordinal) => + Json(/*lang=json,strict*/ + """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0" }, { "file": "9.3.0.amend-1.yaml", "target": null } ] }""" + ), + var p when p.EndsWith("/9.3.0.amend-1.yaml", StringComparison.Ordinal) => Yaml(amendBundle), + _ => Yaml(SampleBundle) + } + ); var (errors, warnings, emitError, emitWarning) = Diagnostics(); using var fetcher = CreateFetcher(handler); - var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: "9.3.0", emitError, emitWarning, TestContext.Current.CancellationToken); + var bundles = + await fetcher.FetchAsync( + BaseUri, + "elasticsearch", + version: "9.3.0", + emitError, + emitWarning, + TestContext.Current.CancellationToken + ); errors.Should().BeEmpty(); warnings.Should().BeEmpty(); handler.RequestedPaths.Should().Contain("/bundle/elasticsearch/9.3.0.amend-1.yaml"); bundles.Should().ContainSingle(); - bundles[0].Entries.Select(e => e.Title) - .Should().BeEquivalentTo("Sample enhancement", "Amended fix"); + bundles[0].Entries.Select(e => e.Title).Should().BeEquivalentTo("Sample enhancement", "Amended fix"); } [Fact] public async Task FetchAsync_WithOtherVersion_DoesNotDownloadUnrelatedAmend() { - var handler = new StubHandler(req => - req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) - ? Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.4.0.yaml", "target": "9.4.0" }, { "file": "9.3.0.yaml", "target": "9.3.0" }, { "file": "9.3.0.amend-1.yaml", "target": null } ] }""") - : Yaml(SampleBundle)); + var handler = new StubHandler( + req => + req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) + ? Json(/*lang=json,strict*/ + """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.4.0.yaml", "target": "9.4.0" }, { "file": "9.3.0.yaml", "target": "9.3.0" }, { "file": "9.3.0.amend-1.yaml", "target": null } ] }""" + ) + : Yaml(SampleBundle) + ); var (errors, _, emitError, emitWarning) = Diagnostics(); using var fetcher = CreateFetcher(handler); - _ = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: "9.4.0", emitError, emitWarning, TestContext.Current.CancellationToken); + _ = + await fetcher.FetchAsync( + BaseUri, + "elasticsearch", + version: "9.4.0", + emitError, + emitWarning, + TestContext.Current.CancellationToken + ); errors.Should().BeEmpty(); handler.RequestedPaths.Should().Contain(p => p.EndsWith("/9.4.0.yaml", StringComparison.Ordinal)); @@ -176,7 +231,8 @@ public async Task FetchAsync_WithVersion_FileIdentityRetractionApplies() // A resolved parent whose entries carry file identities, and a legacy amend that retracts one // of them by file identity: the version-filtered fetch must return the amended result. // language=yaml - const string parentBundle = """ + const string parentBundle = + """ products: - product: elasticsearch target: 9.3.0 @@ -195,29 +251,45 @@ public async Task FetchAsync_WithVersion_FileIdentityRetractionApplies() title: Kept enhancement """; // language=yaml - const string amendBundle = """ + const string amendBundle = + """ exclude-entries: - file: name: 1-old.yaml checksum: deadbeef """; - var handler = new StubHandler(req => req.RequestUri!.AbsolutePath switch - { - var p when p.EndsWith("/registry.json", StringComparison.Ordinal) => - Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0" }, { "file": "9.3.0.amend-1.yaml", "target": null } ] }"""), - var p when p.EndsWith("/9.3.0.amend-1.yaml", StringComparison.Ordinal) => Yaml(amendBundle), - _ => Yaml(parentBundle) - }); + var handler = new StubHandler( + req => + req.RequestUri!.AbsolutePath switch + { + var p when p.EndsWith("/registry.json", StringComparison.Ordinal) => + Json(/*lang=json,strict*/ + """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0" }, { "file": "9.3.0.amend-1.yaml", "target": null } ] }""" + ), + var p when p.EndsWith("/9.3.0.amend-1.yaml", StringComparison.Ordinal) => Yaml(amendBundle), + _ => Yaml(parentBundle) + } + ); var (errors, warnings, emitError, emitWarning) = Diagnostics(); using var fetcher = CreateFetcher(handler); - var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: "9.3.0", emitError, emitWarning, TestContext.Current.CancellationToken); + var bundles = + await fetcher.FetchAsync( + BaseUri, + "elasticsearch", + version: "9.3.0", + emitError, + emitWarning, + TestContext.Current.CancellationToken + ); errors.Should().BeEmpty(); warnings.Should().BeEmpty(); bundles.Should().ContainSingle(); - bundles[0].Entries.Select(e => e.Title) - .Should().BeEquivalentTo(["Kept enhancement"], "the amend retracts the entry by file identity"); + bundles[0].Entries.Select(e => e.Title).Should().BeEquivalentTo( + ["Kept enhancement"], + "the amend retracts the entry by file identity" + ); } [Fact] @@ -227,7 +299,8 @@ public async Task FetchAsync_RegistryNotFound_EmitsErrorAndReturnsEmpty() var (errors, _, emitError, emitWarning) = Diagnostics(); using var fetcher = CreateFetcher(handler); - var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); + var bundles = + await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); bundles.Should().BeEmpty(); errors.Should().ContainSingle().Which.Should().Contain("registry"); @@ -236,14 +309,19 @@ public async Task FetchAsync_RegistryNotFound_EmitsErrorAndReturnsEmpty() [Fact] public async Task FetchAsync_BundleNotFound_EmitsWarningAndSkipsBundle() { - var handler = new StubHandler(req => - req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) - ? Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0" } ] }""") - : new HttpResponseMessage(HttpStatusCode.NotFound)); + var handler = new StubHandler( + req => + req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) + ? Json(/*lang=json,strict*/ + """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0" } ] }""" + ) + : new HttpResponseMessage(HttpStatusCode.NotFound) + ); var (errors, warnings, emitError, emitWarning) = Diagnostics(); using var fetcher = CreateFetcher(handler); - var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); + var bundles = + await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); bundles.Should().BeEmpty(); errors.Should().BeEmpty(); @@ -253,12 +331,14 @@ public async Task FetchAsync_BundleNotFound_EmitsWarningAndSkipsBundle() [Fact] public async Task FetchAsync_SchemaVersionTooNew_EmitsError() { - var handler = new StubHandler(_ => - Json(/*lang=json,strict*/ """{ "schema_version": 999, "product": "elasticsearch", "bundles": [] }""")); + var handler = new StubHandler( + _ => Json(/*lang=json,strict*/ """{ "schema_version": 999, "product": "elasticsearch", "bundles": [] }""") + ); var (errors, _, emitError, emitWarning) = Diagnostics(); using var fetcher = CreateFetcher(handler); - var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); + var bundles = + await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); bundles.Should().BeEmpty(); errors.Should().ContainSingle().Which.Should().Contain("schema version"); @@ -279,7 +359,8 @@ public async Task FetchAsync_InvalidProduct_EmitsErrorAndDoesNotHitCdn(string pr var (errors, _, emitError, emitWarning) = Diagnostics(); using var fetcher = CreateFetcher(handler); - var bundles = await fetcher.FetchAsync(BaseUri, product, version: null, emitError, emitWarning, TestContext.Current.CancellationToken); + var bundles = + await fetcher.FetchAsync(BaseUri, product, version: null, emitError, emitWarning, TestContext.Current.CancellationToken); bundles.Should().BeEmpty(); errors.Should().ContainSingle().Which.Should().Contain("Invalid changelog product"); @@ -289,22 +370,28 @@ public async Task FetchAsync_InvalidProduct_EmitsErrorAndDoesNotHitCdn(string pr [Fact] public async Task FetchAsync_WithETag_UsesCachedBundleOnSecondCall() { - var handler = new StubHandler(req => - req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) - ? Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": "abc123" } ] }""") - : Yaml(SampleBundle)); + var handler = new StubHandler( + req => + req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) + ? Json(/*lang=json,strict*/ + """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": "abc123" } ] }""" + ) + : Yaml(SampleBundle) + ); var (errors, warnings, emitError, emitWarning) = Diagnostics(); var fs = new MockFileSystem(); using var fetcher = new CdnChangelogFetcher(NullLoggerFactory.Instance, fs, handler); // First call — should fetch from CDN - var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); + var bundles = + await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); bundles.Should().ContainSingle(); handler.CallCount.Should().Be(2, "registry + bundle"); // Second call — bundle should come from cache (only registry re-fetched) - var bundles2 = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); + var bundles2 = + await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); bundles2.Should().ContainSingle(); handler.CallCount.Should().Be(3, "only registry fetched again; bundle served from memory cache"); errors.Should().BeEmpty(); @@ -313,15 +400,20 @@ public async Task FetchAsync_WithETag_UsesCachedBundleOnSecondCall() [Fact] public async Task FetchAsync_WithETag_WritesCacheToDisk() { - var handler = new StubHandler(req => - req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) - ? Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": "deadbeef" } ] }""") - : Yaml(SampleBundle)); + var handler = new StubHandler( + req => + req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) + ? Json(/*lang=json,strict*/ + """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": "deadbeef" } ] }""" + ) + : Yaml(SampleBundle) + ); var (errors, _, emitError, emitWarning) = Diagnostics(); var fs = new MockFileSystem(); using var fetcher = new CdnChangelogFetcher(NullLoggerFactory.Instance, fs, handler); - _ = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); + _ = + await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); var expectedPath = Path.Join(Paths.ApplicationData.FullName, "changelog-bundles", "changelog-elasticsearch-9.3.0.yaml-deadbeef"); fs.File.Exists(expectedPath).Should().BeTrue("bundle should be written to disk cache"); @@ -337,14 +429,19 @@ public async Task FetchAsync_WithETag_ReadsCacheFromDisk() fs.Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!); fs.File.WriteAllText(cachePath, SampleBundle); - var handler = new StubHandler(req => - req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) - ? Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": "cached1" } ] }""") - : throw new InvalidOperationException("Should not fetch bundle from CDN")); + var handler = new StubHandler( + req => + req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) + ? Json(/*lang=json,strict*/ + """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": "cached1" } ] }""" + ) + : throw new InvalidOperationException("Should not fetch bundle from CDN") + ); var (errors, warnings, emitError, emitWarning) = Diagnostics(); using var fetcher = new CdnChangelogFetcher(NullLoggerFactory.Instance, fs, handler); - var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); + var bundles = + await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); bundles.Should().ContainSingle(); handler.CallCount.Should().Be(1, "only registry should be fetched; bundle served from disk"); @@ -354,21 +451,27 @@ public async Task FetchAsync_WithETag_ReadsCacheFromDisk() [Fact] public async Task FetchAsync_NullETag_AlwaysFetchesFromCdn() { - var handler = new StubHandler(req => - req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) - ? Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": null } ] }""") - : Yaml(SampleBundle)); + var handler = new StubHandler( + req => + req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) + ? Json(/*lang=json,strict*/ + """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": null } ] }""" + ) + : Yaml(SampleBundle) + ); var (errors, _, emitError, emitWarning) = Diagnostics(); var fs = new MockFileSystem(); using var fetcher = new CdnChangelogFetcher(NullLoggerFactory.Instance, fs, handler); // First call - _ = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); + _ = + await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); handler.CallCount.Should().Be(2); // Second call — no caching, so bundle is fetched again - _ = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); + _ = + await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); handler.CallCount.Should().Be(4, "without ETag, both registry and bundle are fetched each time"); errors.Should().BeEmpty(); } @@ -377,21 +480,27 @@ public async Task FetchAsync_NullETag_AlwaysFetchesFromCdn() public async Task FetchAsync_ChangedETag_FetchesNewBundle() { var etag = "v1"; - var handler = new StubHandler(req => - req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) - ? Json($$"""{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": "{{etag}}" } ] }""") - : Yaml(SampleBundle)); + var handler = new StubHandler( + req => + req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal) + ? Json( + $$"""{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": "{{etag}}" } ] }""" + ) + : Yaml(SampleBundle) + ); var (errors, _, emitError, emitWarning) = Diagnostics(); var fs = new MockFileSystem(); using var fetcher = new CdnChangelogFetcher(NullLoggerFactory.Instance, fs, handler); - _ = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); + _ = + await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); handler.CallCount.Should().Be(2); // Simulate a new etag by creating a new fetcher (in real usage the registry returns a different etag) etag = "v2"; - _ = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); + _ = + await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken); handler.CallCount.Should().Be(4, "new ETag means cache miss, bundle re-downloaded"); errors.Should().BeEmpty(); } diff --git a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogKeysTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogKeysTests.cs index 707e0f9e27..928d814da0 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogKeysTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogKeysTests.cs @@ -15,8 +15,7 @@ public class ChangelogKeysTests [InlineData("cloud_hosted")] [InlineData("a")] [InlineData("Agent2")] - public void IsValidProduct_ValidNames_ReturnsTrue(string product) => - ChangelogKeys.IsValidProduct(product).Should().BeTrue(); + public void IsValidProduct_ValidNames_ReturnsTrue(string product) => ChangelogKeys.IsValidProduct(product).Should().BeTrue(); [Theory] [InlineData(null)] @@ -28,15 +27,13 @@ public void IsValidProduct_ValidNames_ReturnsTrue(string product) => [InlineData("..")] [InlineData("foo bar")] [InlineData("foo/bar")] - public void IsValidProduct_InvalidNames_ReturnsFalse(string? product) => - ChangelogKeys.IsValidProduct(product).Should().BeFalse(); + public void IsValidProduct_InvalidNames_ReturnsFalse(string? product) => ChangelogKeys.IsValidProduct(product).Should().BeFalse(); [Theory] [InlineData("elastic")] [InlineData("acme-corp")] [InlineData("ACME1")] - public void IsValidOrg_ValidLogins_ReturnsTrue(string org) => - ChangelogKeys.IsValidOrg(org).Should().BeTrue(); + public void IsValidOrg_ValidLogins_ReturnsTrue(string org) => ChangelogKeys.IsValidOrg(org).Should().BeTrue(); [Theory] [InlineData(null)] @@ -49,8 +46,7 @@ public void IsValidOrg_ValidLogins_ReturnsTrue(string org) => [InlineData("..")] [InlineData("acme corp")] [InlineData("acme/corp")] - public void IsValidOrg_InvalidLogins_ReturnsFalse(string? org) => - ChangelogKeys.IsValidOrg(org).Should().BeFalse(); + public void IsValidOrg_InvalidLogins_ReturnsFalse(string? org) => ChangelogKeys.IsValidOrg(org).Should().BeFalse(); [Theory] [InlineData("elasticsearch")] @@ -58,8 +54,7 @@ public void IsValidOrg_InvalidLogins_ReturnsFalse(string? org) => [InlineData("apm.agent")] [InlineData("my_repo")] [InlineData("repo-1")] - public void IsValidRepo_ValidNames_ReturnsTrue(string repo) => - ChangelogKeys.IsValidRepo(repo).Should().BeTrue(); + public void IsValidRepo_ValidNames_ReturnsTrue(string repo) => ChangelogKeys.IsValidRepo(repo).Should().BeTrue(); [Theory] [InlineData(null)] @@ -70,8 +65,7 @@ public void IsValidRepo_ValidNames_ReturnsTrue(string repo) => [InlineData("..")] [InlineData("a/b")] [InlineData("a b")] - public void IsValidRepo_InvalidNames_ReturnsFalse(string? repo) => - ChangelogKeys.IsValidRepo(repo).Should().BeFalse(); + public void IsValidRepo_InvalidNames_ReturnsFalse(string? repo) => ChangelogKeys.IsValidRepo(repo).Should().BeFalse(); [Theory] [InlineData("main")] @@ -81,8 +75,7 @@ public void IsValidRepo_InvalidNames_ReturnsFalse(string? repo) => [InlineData("feature/foo")] [InlineData("release/8.x")] [InlineData("a_b")] - public void IsValidBranch_ValidBranches_ReturnsTrue(string branch) => - ChangelogKeys.IsValidBranch(branch).Should().BeTrue(); + public void IsValidBranch_ValidBranches_ReturnsTrue(string branch) => ChangelogKeys.IsValidBranch(branch).Should().BeTrue(); [Theory] [InlineData(null)] @@ -96,15 +89,13 @@ public void IsValidBranch_ValidBranches_ReturnsTrue(string branch) => [InlineData("..")] [InlineData("feature/..")] [InlineData("a b")] - public void IsValidBranch_InvalidBranches_ReturnsFalse(string? branch) => - ChangelogKeys.IsValidBranch(branch).Should().BeFalse(); + public void IsValidBranch_InvalidBranches_ReturnsFalse(string? branch) => ChangelogKeys.IsValidBranch(branch).Should().BeFalse(); [Theory] [InlineData("entry.yaml")] [InlineData("registry.json")] [InlineData("9.0.0.yaml")] - public void IsSafeFileName_SingleSegments_ReturnsTrue(string fileName) => - ChangelogKeys.IsSafeFileName(fileName).Should().BeTrue(); + public void IsSafeFileName_SingleSegments_ReturnsTrue(string fileName) => ChangelogKeys.IsSafeFileName(fileName).Should().BeTrue(); [Theory] [InlineData(null)] @@ -119,28 +110,25 @@ public void IsSafeFileName_TraversalOrMultiSegment_ReturnsFalse(string? fileName [Fact] public void BundleFileKey_ComposesArtifactRootKey() => - ChangelogKeys.BundleFileKey("elasticsearch", "9.0.0.yaml") - .Should().Be("bundle/elasticsearch/9.0.0.yaml"); + ChangelogKeys.BundleFileKey("elasticsearch", "9.0.0.yaml").Should().Be("bundle/elasticsearch/9.0.0.yaml"); [Fact] public void ChangelogFileKey_ComposesArtifactRootKey() => - ChangelogKeys.ChangelogFileKey("elastic", "kibana", "main", "entry.yaml") - .Should().Be("changelog/elastic/kibana/main/entry.yaml"); + ChangelogKeys.ChangelogFileKey("elastic", "kibana", "main", "entry.yaml").Should().Be("changelog/elastic/kibana/main/entry.yaml"); [Fact] public void ChangelogFileKey_BranchSlashesBecomeKeySegments() => ChangelogKeys.ChangelogFileKey("elastic", "kibana", "feature/foo", "entry.yaml") - .Should().Be("changelog/elastic/kibana/feature/foo/entry.yaml"); + .Should() + .Be("changelog/elastic/kibana/feature/foo/entry.yaml"); [Fact] public void BundleRegistryKey_ComposesManifestKey() => - ChangelogKeys.BundleRegistryKey("elasticsearch") - .Should().Be("bundle/elasticsearch/registry.json"); + ChangelogKeys.BundleRegistryKey("elasticsearch").Should().Be("bundle/elasticsearch/registry.json"); [Fact] public void ChangelogRegistryKey_ComposesManifestKeyFromGroup() => - ChangelogKeys.ChangelogRegistryKey("elastic/kibana/main") - .Should().Be("changelog/elastic/kibana/main/registry.json"); + ChangelogKeys.ChangelogRegistryKey("elastic/kibana/main").Should().Be("changelog/elastic/kibana/main/registry.json"); [Theory] [InlineData("bundle/elasticsearch/9.0.0.yaml", "elasticsearch")] @@ -159,8 +147,7 @@ public void ExtractBundleGroup_BundleKeys_ReturnsProduct(string key, string expe [InlineData("bundle/../entry.yaml")] [InlineData("bundle/foo.bar/entry.yaml")] [InlineData("bundle/elastic search/entry.yaml")] - public void ExtractBundleGroup_NonBundleKeys_ReturnsNull(string key) => - ChangelogKeys.ExtractBundleGroup(key).Should().BeNull(); + public void ExtractBundleGroup_NonBundleKeys_ReturnsNull(string key) => ChangelogKeys.ExtractBundleGroup(key).Should().BeNull(); [Theory] [InlineData("changelog/elastic/kibana/main/entry.yaml", "elastic/kibana/main")] @@ -182,18 +169,15 @@ public void ExtractChangelogGroup_EntryKeys_ReturnsPool(string key, string expec [InlineData("changelog/elastic/../main/entry.yaml")] [InlineData("changelog/acme.corp/widgets/main/entry.yaml")] [InlineData("changelog/elastic/elastic search/main/entry.yaml")] - public void ExtractChangelogGroup_NonEntryKeys_ReturnsNull(string key) => - ChangelogKeys.ExtractChangelogGroup(key).Should().BeNull(); + public void ExtractChangelogGroup_NonEntryKeys_ReturnsNull(string key) => ChangelogKeys.ExtractChangelogGroup(key).Should().BeNull(); [Fact] public void BundleSegments_ReturnsPrefixAndProduct() => - ChangelogKeys.BundleSegments("elasticsearch") - .Should().Equal("bundle", "elasticsearch"); + ChangelogKeys.BundleSegments("elasticsearch").Should().Equal("bundle", "elasticsearch"); [Fact] public void PoolSegments_ExpandsBranchSlashesIntoSegments() => - ChangelogKeys.PoolSegments("elastic", "kibana", "feature/foo") - .Should().Equal("changelog", "elastic", "kibana", "feature", "foo"); + ChangelogKeys.PoolSegments("elastic", "kibana", "feature/foo").Should().Equal("changelog", "elastic", "kibana", "feature", "foo"); [Theory] // Bundle index (artifact-root): bundle/{product}/registry.json — exactly one product segment. @@ -218,8 +202,7 @@ public void PoolSegments_ExpandsBranchSlashesIntoSegments() => // Branch stored verbatim: a branch's own '/' become additional, valid key segments. [InlineData("changelog/elastic/kibana/feature/foo/registry.json")] [InlineData("changelog/elastic/kibana/release/8.x/registry.json")] - public void IsRegistry_ValidArtifactRootKeys_ReturnsTrue(string key) => - ChangelogKeys.IsRegistry(key).Should().BeTrue(); + public void IsRegistry_ValidArtifactRootKeys_ReturnsTrue(string key) => ChangelogKeys.IsRegistry(key).Should().BeTrue(); [Theory] [InlineData("")] @@ -258,6 +241,5 @@ public void IsRegistry_ValidArtifactRootKeys_ReturnsTrue(string key) => // Spaces (and other out-of-class characters) are rejected. [InlineData("bundle/elastic search/registry.json")] [InlineData("changelog/elastic/elastic search/main/registry.json")] - public void IsRegistry_InvalidKeys_ReturnsFalse(string key) => - ChangelogKeys.IsRegistry(key).Should().BeFalse(); + public void IsRegistry_InvalidKeys_ReturnsFalse(string key) => ChangelogKeys.IsRegistry(key).Should().BeFalse(); } diff --git a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogTextUtilitiesTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogTextUtilitiesTests.cs index 3222df485d..7d0e20ff02 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogTextUtilitiesTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogTextUtilitiesTests.cs @@ -73,15 +73,14 @@ public void StripSquareBracketPrefix_RemovesPrefix(string input, string expected [InlineData("+ Plus", true)] [InlineData("\u2013 En dash", true)] [InlineData("\u2014 Em dash", true)] - public void TitleNeedsDefensiveYamlQuoting_DetectsBulletLikeScalars(string? input, bool expected) - { + public void TitleNeedsDefensiveYamlQuoting_DetectsBulletLikeScalars(string? input, bool expected) => ChangelogTextUtilities.TitleNeedsDefensiveYamlQuoting(input).Should().Be(expected); - } [Theory] [InlineData("https://github.com/elastic/elasticsearch/pull/123", 123)] [InlineData("elastic/elasticsearch#456", 456)] [InlineData("123", null)] // No default owner/repo + public void ExtractPrNumber_ExtractsNumber(string input, int? expected) { var result = ChangelogTextUtilities.ExtractPrNumber(input); @@ -150,6 +149,7 @@ public void ParseRepository_ParsesCorrectly(string input, string? expectedOwner, [Theory] [InlineData("Add new feature to API", "add-new-feature-to-api")] [InlineData("Fix bug in the search API endpoint handler", "fix-bug-in-the-search-api")] // Takes first 6 words by default + [InlineData("", "untitled")] public void GenerateSlug_GeneratesSlug(string input, string expected) { @@ -188,11 +188,7 @@ public void HasVisibleLinks_WithMixedLinks_ReturnsTrue() [Fact] public void HasVisibleLinks_WithPublicLinks_ReturnsTrue() { - var entry = new ChangelogEntry - { - Prs = ["123"], - Issues = ["456"] - }; + var entry = new ChangelogEntry { Prs = ["123"], Issues = ["456"] }; var result = ChangelogTextUtilities.HasVisibleLinks(entry, "elasticsearch", false); @@ -202,11 +198,7 @@ public void HasVisibleLinks_WithPublicLinks_ReturnsTrue() [Fact] public void HasVisibleLinks_WithNoLinks_ReturnsFalse() { - var entry = new ChangelogEntry - { - Prs = null, - Issues = null - }; + var entry = new ChangelogEntry { Prs = null, Issues = null }; var result = ChangelogTextUtilities.HasVisibleLinks(entry, "elasticsearch", false); @@ -216,11 +208,7 @@ public void HasVisibleLinks_WithNoLinks_ReturnsFalse() [Fact] public void HasVisibleLinks_WithEmptyArrays_ReturnsFalse() { - var entry = new ChangelogEntry - { - Prs = [], - Issues = [] - }; + var entry = new ChangelogEntry { Prs = [], Issues = [] }; var result = ChangelogTextUtilities.HasVisibleLinks(entry, "elasticsearch", false); diff --git a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/PublishBlockerExtensionsTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/PublishBlockerExtensionsTests.cs index 2c7602347e..eaad18ce8a 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/PublishBlockerExtensionsTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/PublishBlockerExtensionsTests.cs @@ -116,12 +116,7 @@ public void ShouldBlock_ExcludeArea_IsCaseInsensitive() [Fact] public void ShouldBlock_ExcludeArea_MatchAll_Blocks_WhenAllAreasMatch() { - var blocker = new PublishBlocker - { - Areas = ["Internal", "Search"], - AreasMode = FieldMode.Exclude, - MatchAreas = MatchMode.All - }; + var blocker = new PublishBlocker { Areas = ["Internal", "Search"], AreasMode = FieldMode.Exclude, MatchAreas = MatchMode.All }; var entry = new ChangelogEntry { Title = "Test", Type = ChangelogEntryType.Feature, Areas = ["Internal", "Search"] }; blocker.ShouldBlock(entry).Should().BeTrue(); @@ -130,12 +125,7 @@ public void ShouldBlock_ExcludeArea_MatchAll_Blocks_WhenAllAreasMatch() [Fact] public void ShouldBlock_ExcludeArea_MatchAll_Allows_WhenNotAllAreasMatch() { - var blocker = new PublishBlocker - { - Areas = ["Internal"], - AreasMode = FieldMode.Exclude, - MatchAreas = MatchMode.All - }; + var blocker = new PublishBlocker { Areas = ["Internal"], AreasMode = FieldMode.Exclude, MatchAreas = MatchMode.All }; // Entry has ["Search", "Internal"]. MatchAll means ALL entry areas must be in the exclude list. // "Search" is NOT in ["Internal"], so not all match → allowed. var entry = new ChangelogEntry { Title = "Test", Type = ChangelogEntryType.Feature, Areas = ["Search", "Internal"] }; @@ -183,12 +173,7 @@ public void ShouldBlock_IncludeArea_MatchAll_Allows_WhenAllAreasInIncludeList() [Fact] public void ShouldBlock_IncludeArea_MatchAll_Blocks_WhenNotAllAreasInIncludeList() { - var blocker = new PublishBlocker - { - Areas = ["Search"], - AreasMode = FieldMode.Include, - MatchAreas = MatchMode.All - }; + var blocker = new PublishBlocker { Areas = ["Search"], AreasMode = FieldMode.Include, MatchAreas = MatchMode.All }; // Entry has ["Search", "Internal"]. "Internal" is NOT in include list → not all match → blocked var entry = new ChangelogEntry { Title = "Test", Type = ChangelogEntryType.Feature, Areas = ["Search", "Internal"] }; diff --git a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ReleaseNotesSerializationTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ReleaseNotesSerializationTests.cs index 91f038a0db..390e1630ab 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ReleaseNotesSerializationTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ReleaseNotesSerializationTests.cs @@ -17,17 +17,15 @@ public void SerializeEntry_TitleStartingWithDash_EmitsDoubleQuotedTitleAndRoundT { Title = "- Manual leading dash", Type = ChangelogEntryType.Feature, - Products = - [ - new ProductReference { ProductId = "kibana", Lifecycle = Lifecycle.Ga } - ] + Products = [new ProductReference { ProductId = "kibana", Lifecycle = Lifecycle.Ga }] }; var yaml = ReleaseNotesSerialization.SerializeEntry(entry); (yaml.Contains("title: \"- Manual leading dash\"", StringComparison.Ordinal) || - yaml.Contains("title: '- Manual leading dash'", StringComparison.Ordinal)) - .Should().BeTrue("title must be a quoted YAML scalar so '-' is not parsed as a list marker"); + yaml.Contains("title: '- Manual leading dash'", StringComparison.Ordinal)).Should().BeTrue( + "title must be a quoted YAML scalar so '-' is not parsed as a list marker" + ); var roundTrip = ReleaseNotesSerialization.DeserializeEntry(yaml); roundTrip.Title.Should().Be("- Manual leading dash"); @@ -40,10 +38,7 @@ public void SerializeEntry_PlainTitle_DoesNotForceDoubleQuotes() { Title = "Enable numerical id service", Type = ChangelogEntryType.Feature, - Products = - [ - new ProductReference { ProductId = "kibana", Lifecycle = Lifecycle.Ga } - ] + Products = [new ProductReference { ProductId = "kibana", Lifecycle = Lifecycle.Ga }] }; var yaml = ReleaseNotesSerialization.SerializeEntry(entry); @@ -59,10 +54,7 @@ public void SerializeEntry_MultilineTitleStartingWithDash_RoundTrips() { Title = "- line1\nline2", Type = ChangelogEntryType.Feature, - Products = - [ - new ProductReference { ProductId = "kibana", Lifecycle = Lifecycle.Ga } - ] + Products = [new ProductReference { ProductId = "kibana", Lifecycle = Lifecycle.Ga }] }; var yaml = ReleaseNotesSerialization.SerializeEntry(entry); @@ -92,19 +84,16 @@ public void SerializeEntry_AdversarialTitle_RoundTripsWithoutInjection(string ad { Title = adversarialTitle, Type = ChangelogEntryType.Feature, - Products = - [ - new ProductReference { ProductId = "kibana", Lifecycle = Lifecycle.Ga } - ] + Products = [new ProductReference { ProductId = "kibana", Lifecycle = Lifecycle.Ga }] }; var yaml = ReleaseNotesSerialization.SerializeEntry(entry); var roundTrip = ReleaseNotesSerialization.DeserializeEntry(yaml); - roundTrip.Title.Should().Be(adversarialTitle, - "adversarial titles must round-trip exactly without leaking into surrounding YAML structure"); - roundTrip.Type.Should().Be(ChangelogEntryType.Feature, - "adversarial title must not change unrelated fields"); + roundTrip.Title + .Should() + .Be(adversarialTitle, "adversarial titles must round-trip exactly without leaking into surrounding YAML structure"); + roundTrip.Type.Should().Be(ChangelogEntryType.Feature, "adversarial title must not change unrelated fields"); } [Fact] @@ -115,10 +104,7 @@ public void SerializeEntry_DescriptionWithYamlBlockMarkers_RoundTrips() Title = "Plain title", Description = "First line\n---\nfake: document\n...\nclosing marker", Type = ChangelogEntryType.Feature, - Products = - [ - new ProductReference { ProductId = "kibana", Lifecycle = Lifecycle.Ga } - ] + Products = [new ProductReference { ProductId = "kibana", Lifecycle = Lifecycle.Ga }] }; var yaml = ReleaseNotesSerialization.SerializeEntry(entry); @@ -137,10 +123,7 @@ public void SerializeEntry_InjectedFieldInTitle_DoesNotPolluteOtherFields() { Title = "Legit\nimpact: attacker-set\naction: rm -rf /", Type = ChangelogEntryType.Feature, - Products = - [ - new ProductReference { ProductId = "kibana", Lifecycle = Lifecycle.Ga } - ] + Products = [new ProductReference { ProductId = "kibana", Lifecycle = Lifecycle.Ga }] }; var yaml = ReleaseNotesSerialization.SerializeEntry(entry); diff --git a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/VersionLifecycleInferenceTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/VersionLifecycleInferenceTests.cs index 12ce46f89a..1f4b7c37c5 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/VersionLifecycleInferenceTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/VersionLifecycleInferenceTests.cs @@ -17,8 +17,6 @@ public class VersionLifecycleInferenceTests [InlineData("9.2.0-rc.1", "ga")] [InlineData("2026-07-21", "ga")] [InlineData("2025-06-01", "ga")] - public void InferLifecycle_InfersCorrectly(string version, string expected) - { + public void InferLifecycle_InfersCorrectly(string version, string expected) => VersionLifecycleInference.InferLifecycle(version).Should().Be(expected); - } } diff --git a/tests/Elastic.Documentation.Configuration.Tests/SiteNavigationFileTests.cs b/tests/Elastic.Documentation.Configuration.Tests/SiteNavigationFileTests.cs index ab502acf33..8a74a46695 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/SiteNavigationFileTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/SiteNavigationFileTests.cs @@ -13,7 +13,8 @@ public class SiteNavigationFileTests public void DeserializesSiteNavigationFile() { // language=yaml - var yaml = """ + var yaml = + """ phantoms: - toc: elasticsearch://reference - toc: docs-content:// @@ -53,7 +54,8 @@ public void DeserializesSiteNavigationFile() public void DeserializesSiteNavigationFileWithNestedChildren() { // language=yaml - var yaml = """ + var yaml = + """ toc: - toc: platform path_prefix: /platform @@ -103,7 +105,8 @@ public void DeserializesWithMissingPath() public void PreservesSchemeWhenPresent() { // language=yaml - var yaml = """ + var yaml = + """ toc: - toc: elasticsearch://reference/current - toc: kibana://reference/8.0 @@ -128,7 +131,8 @@ public void PreservesSchemeWhenPresent() public void DeserializesIslandOnTocEntry() { // language=yaml - var yaml = """ + var yaml = + """ toc: - toc: observability:// path_prefix: observability @@ -159,7 +163,8 @@ public void ThrowsExceptionForInvalidUri() var act = () => SiteNavigationFile.Deserialize(yaml); - act.Should().Throw() + act.Should() + .Throw() .WithInnerException() .WithMessage("Invalid TOC source: '://invalid' could not be parsed as a URI"); } @@ -176,15 +181,15 @@ public void UnknownKeyThrows() // A typo (no 'toc:' or 'section:' key) must throw rather than silently drop the entry. var act = () => SiteNavigationFile.Deserialize(yaml); - act.Should().Throw() - .WithMessage("*has no 'toc:' key*"); + act.Should().Throw().WithMessage("*has no 'toc:' key*"); } [Fact] public void DeserializesSectionWithChildren() { // language=yaml - var yaml = """ + var yaml = + """ toc: - section: Guides children: @@ -214,7 +219,8 @@ public void DeserializesSectionWithChildren() public void DeserializesExternalSection() { // language=yaml - var yaml = """ + var yaml = + """ toc: - section: APIs external: https://www.elastic.co/docs/api/ diff --git a/tests/Elastic.Documentation.Configuration.Tests/Text/Utf8TextNormalizationTests.cs b/tests/Elastic.Documentation.Configuration.Tests/Text/Utf8TextNormalizationTests.cs index 576f92ca7b..aa7515c1ab 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/Text/Utf8TextNormalizationTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/Text/Utf8TextNormalizationTests.cs @@ -50,8 +50,7 @@ public void StripLeadingUtf8Bom_StringWithSingleLeadingBom_RemovesBom() public void StripLeadingUtf8Bom_StringWithTwoConsecutiveLeadingBoms_RemovesBothBoms() { const string content = "type: feature\ntitle: Test changelog entry"; - var input = Utf8TextNormalization.Utf8BomChar.ToString() + - Utf8TextNormalization.Utf8BomChar + content; + var input = Utf8TextNormalization.Utf8BomChar.ToString() + Utf8TextNormalization.Utf8BomChar + content; var result = Utf8TextNormalization.StripLeadingUtf8Bom(input); @@ -62,9 +61,10 @@ public void StripLeadingUtf8Bom_StringWithTwoConsecutiveLeadingBoms_RemovesBothB public void StripLeadingUtf8Bom_StringWithThreeConsecutiveLeadingBoms_RemovesAllBoms() { const string content = "type: feature\ntitle: Test changelog entry"; - var input = Utf8TextNormalization.Utf8BomChar.ToString() + - Utf8TextNormalization.Utf8BomChar + - Utf8TextNormalization.Utf8BomChar + content; + var input = Utf8TextNormalization.Utf8BomChar.ToString() + + Utf8TextNormalization.Utf8BomChar + + Utf8TextNormalization.Utf8BomChar + + content; var result = Utf8TextNormalization.StripLeadingUtf8Bom(input); @@ -74,8 +74,7 @@ public void StripLeadingUtf8Bom_StringWithThreeConsecutiveLeadingBoms_RemovesAll [Fact] public void StripLeadingUtf8Bom_StringOnlyBoms_ReturnsEmpty() { - var input = Utf8TextNormalization.Utf8BomChar.ToString() + - Utf8TextNormalization.Utf8BomChar; + var input = Utf8TextNormalization.Utf8BomChar.ToString() + Utf8TextNormalization.Utf8BomChar; var result = Utf8TextNormalization.StripLeadingUtf8Bom(input); @@ -163,14 +162,8 @@ public void HasUtf8Bom_NormalTextBytes_ReturnsFalse() } [Fact] - public void Utf8BomChar_MatchesExpectedValue() - { - Utf8TextNormalization.Utf8BomChar.Should().Be('\uFEFF'); - } + public void Utf8BomChar_MatchesExpectedValue() => Utf8TextNormalization.Utf8BomChar.Should().Be('\uFEFF'); [Fact] - public void Utf8BomBytes_MatchesExpectedSequence() - { - Utf8TextNormalization.Utf8BomBytes.Should().Equal([0xEF, 0xBB, 0xBF]); - } + public void Utf8BomBytes_MatchesExpectedSequence() => Utf8TextNormalization.Utf8BomBytes.Should().Equal([0xEF, 0xBB, 0xBF]); } diff --git a/tests/Elastic.Documentation.Configuration.Tests/UseNavigationPreviewTests.cs b/tests/Elastic.Documentation.Configuration.Tests/UseNavigationPreviewTests.cs index b2cb9bc453..d92d4e6473 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/UseNavigationPreviewTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/UseNavigationPreviewTests.cs @@ -13,7 +13,12 @@ namespace Elastic.Documentation.Configuration.Tests; public class UseNavigationPreviewTests { private static ConfigurationFileProvider CreateProvider(MockFileSystem fileSystem) => - new(NullLoggerFactory.Instance, new ConfigurationFileSystem(fileSystem), skipPrivateRepositories: true, ConfigurationSource.Embedded); + new( + NullLoggerFactory.Instance, + new ConfigurationFileSystem(fileSystem), + skipPrivateRepositories: true, + ConfigurationSource.Embedded + ); private static AssemblyConfiguration CreateConfig(params string[] privateRepoNames) { @@ -29,7 +34,8 @@ public void UseNavigationPreview_ReadsPreviewFile() var provider = CreateProvider(fileSystem); // language=yaml - var previewYaml = """ + var previewYaml = + """ toc: - toc: elasticsearch://reference/elasticsearch path_prefix: reference/elasticsearch @@ -37,7 +43,8 @@ public void UseNavigationPreview_ReadsPreviewFile() """; // language=yaml - var mainYaml = """ + var mainYaml = + """ toc: - toc: elasticsearch://reference/elasticsearch path_prefix: reference/elasticsearch @@ -46,9 +53,7 @@ public void UseNavigationPreview_ReadsPreviewFile() fileSystem.File.WriteAllText(provider.NavigationFile.FullName, mainYaml); // Simulate navigation_preview.yml existing alongside navigation.yml in the same temp dir - var previewPath = Path.Join( - Path.GetDirectoryName(provider.NavigationFile.FullName), - "navigation_preview.yml"); + var previewPath = Path.Join(Path.GetDirectoryName(provider.NavigationFile.FullName), "navigation_preview.yml"); fileSystem.File.WriteAllText(previewPath, previewYaml); // Now simulate what ConfigurationSource.Local does — replace NavigationFile content @@ -70,43 +75,26 @@ public void NavigationPreviewEnabled_ReadsUnderscoredEnvironmentKey() // Regression guard: the ctor-doesn't-normalize trap. // PublishEnvironment.FeatureFlags keys use UPPER_SNAKE yaml convention; FeatureFlags.IsEnabled // looks up normalized lower-kebab keys. ToFeatureFlags() must bridge this via Set(). - var env = new PublishEnvironment - { - FeatureFlags = new Dictionary - { - ["NAVIGATION_PREVIEW"] = true - } - }; + var env = new PublishEnvironment { FeatureFlags = new Dictionary { ["NAVIGATION_PREVIEW"] = true } }; var flags = env.ToFeatureFlags(); - flags.NavigationPreviewEnabled.Should().BeTrue( - "ToFeatureFlags() normalizes UPPER_SNAKE keys through Set() before storing them"); + flags.NavigationPreviewEnabled.Should().BeTrue("ToFeatureFlags() normalizes UPPER_SNAKE keys through Set() before storing them"); } [Fact] public void NavigationPreviewEnabled_FalseWhenNotSet() { - var env = new PublishEnvironment - { - FeatureFlags = [] - }; + var env = new PublishEnvironment { FeatureFlags = [] }; var flags = env.ToFeatureFlags(); - flags.NavigationPreviewEnabled.Should().BeFalse( - "flag must be inert when not declared in the environment"); + flags.NavigationPreviewEnabled.Should().BeFalse("flag must be inert when not declared in the environment"); } [Fact] public void ToFeatureFlags_DoesNotAffectOtherFlags() { // NAVIGATION_PREVIEW enabled must not accidentally enable sibling flags - var env = new PublishEnvironment - { - FeatureFlags = new Dictionary - { - ["NAVIGATION_PREVIEW"] = true - } - }; + var env = new PublishEnvironment { FeatureFlags = new Dictionary { ["NAVIGATION_PREVIEW"] = true } }; var flags = env.ToFeatureFlags(); flags.WebsiteSearchEnabled.Should().BeFalse(); @@ -123,7 +111,8 @@ public void UseNavigationPreview_ThenCreateNavigationFile_StripsPrivateReposFrom var provider = CreateProvider(fileSystem); // language=yaml - var previewYaml = """ + var previewYaml = + """ toc: - toc: public-repo://reference path_prefix: reference diff --git a/tests/Elastic.Documentation.Configuration.Tests/VersionInferenceTests.cs b/tests/Elastic.Documentation.Configuration.Tests/VersionInferenceTests.cs index f3aefa5047..10b9048c8a 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/VersionInferenceTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/VersionInferenceTests.cs @@ -20,35 +20,37 @@ public class VersionInferenceTests /// /// All ProductApplicability product names that should be mapped. /// - private static Dictionary ProductApplicabilityOptions => new() - { - { nameof(ProductApplicability.Ecctl), "cloud-control-ecctl" }, - { nameof(ProductApplicability.Curator), "curator" }, - { nameof(ProductApplicability.ApmAgentAndroid), "edot-android" }, - { nameof(ProductApplicability.ApmAgentDotnet), "apm-agent-dotnet" }, - { nameof(ProductApplicability.ApmAgentGo), "apm-agent-go" }, - { nameof(ProductApplicability.ApmAgentIos), "edot-ios" }, - { nameof(ProductApplicability.ApmAgentJava), "apm-agent-java" }, - { nameof(ProductApplicability.ApmAgentNode), "apm-agent-node" }, - { nameof(ProductApplicability.ApmAgentPhp), "apm-agent-php" }, - { nameof(ProductApplicability.ApmAgentPython), "apm-agent-python" }, - { nameof(ProductApplicability.ApmAgentRuby), "apm-agent-ruby" }, - { nameof(ProductApplicability.ApmAgentRumJs), "apm-agent-rum-js" }, - { nameof(ProductApplicability.EdotIos), "edot-ios" }, - { nameof(ProductApplicability.EdotAndroid), "edot-android" }, - { nameof(ProductApplicability.EdotDotnet), "edot-dotnet" }, - { nameof(ProductApplicability.EdotJava), "edot-java" }, - { nameof(ProductApplicability.EdotNode), "edot-node" }, - { nameof(ProductApplicability.EdotBrowser), "edot-browser" }, - { nameof(ProductApplicability.EdotPhp), "edot-php" }, - { nameof(ProductApplicability.EdotPython), "edot-python" }, - { nameof(ProductApplicability.EdotCfAws), "edot-cf-aws" }, - { nameof(ProductApplicability.EdotCfAzure), "edot-cf-azure" }, - { nameof(ProductApplicability.EdotCfGcp), "edot-cf-gcp" }, - { nameof(ProductApplicability.EdotCollector), "edot-collector" } - }; - - public static TheoryData ProductApplicabilityOptionsAsList => [.. ProductApplicabilityOptions.Select(kvp => (kvp.Key, kvp.Value))]; + private static Dictionary ProductApplicabilityOptions => + new() + { + { nameof(ProductApplicability.Ecctl), "cloud-control-ecctl" }, + { nameof(ProductApplicability.Curator), "curator" }, + { nameof(ProductApplicability.ApmAgentAndroid), "edot-android" }, + { nameof(ProductApplicability.ApmAgentDotnet), "apm-agent-dotnet" }, + { nameof(ProductApplicability.ApmAgentGo), "apm-agent-go" }, + { nameof(ProductApplicability.ApmAgentIos), "edot-ios" }, + { nameof(ProductApplicability.ApmAgentJava), "apm-agent-java" }, + { nameof(ProductApplicability.ApmAgentNode), "apm-agent-node" }, + { nameof(ProductApplicability.ApmAgentPhp), "apm-agent-php" }, + { nameof(ProductApplicability.ApmAgentPython), "apm-agent-python" }, + { nameof(ProductApplicability.ApmAgentRuby), "apm-agent-ruby" }, + { nameof(ProductApplicability.ApmAgentRumJs), "apm-agent-rum-js" }, + { nameof(ProductApplicability.EdotIos), "edot-ios" }, + { nameof(ProductApplicability.EdotAndroid), "edot-android" }, + { nameof(ProductApplicability.EdotDotnet), "edot-dotnet" }, + { nameof(ProductApplicability.EdotJava), "edot-java" }, + { nameof(ProductApplicability.EdotNode), "edot-node" }, + { nameof(ProductApplicability.EdotBrowser), "edot-browser" }, + { nameof(ProductApplicability.EdotPhp), "edot-php" }, + { nameof(ProductApplicability.EdotPython), "edot-python" }, + { nameof(ProductApplicability.EdotCfAws), "edot-cf-aws" }, + { nameof(ProductApplicability.EdotCfAzure), "edot-cf-azure" }, + { nameof(ProductApplicability.EdotCfGcp), "edot-cf-gcp" }, + { nameof(ProductApplicability.EdotCollector), "edot-collector" } + }; + + public static TheoryData ProductApplicabilityOptionsAsList => + [.. ProductApplicabilityOptions.Select(kvp => (kvp.Key, kvp.Value))]; private static VersionsConfiguration CreateVersionsConfiguration() { @@ -56,12 +58,7 @@ private static VersionsConfiguration CreateVersionsConfiguration() foreach (var id in Enum.GetValues()) { - versioningSystems[id] = new VersioningSystem - { - Id = id, - Current = new SemVersion(1, 0, 0), - Base = new SemVersion(1, 0, 0) - }; + versioningSystems[id] = new VersioningSystem { Id = id, Current = new SemVersion(1, 0, 0), Base = new SemVersion(1, 0, 0) }; } return new VersionsConfiguration { VersioningSystems = versioningSystems }; @@ -204,8 +201,13 @@ private static ProductApplicability CreateProductApplicabilityByName(string prod return applicability; } - [Theory(DisplayName = "ProductApplicabilityToProductId returns correct product ID for product {0}"), MemberData(nameof(ProductApplicabilityOptionsAsList))] - public void InferVersionReturnsCorrectVersioningForAllProductApplicabilityProperties(string productApplicabilityEntry, string targetProductId) + [Theory(DisplayName = "ProductApplicabilityToProductId returns correct product ID for product {0}"), MemberData(nameof( + ProductApplicabilityOptionsAsList + ))] + public void InferVersionReturnsCorrectVersioningForAllProductApplicabilityProperties( + string productApplicabilityEntry, + string targetProductId + ) { var versionsConfiguration = CreateVersionsConfiguration(); var productsConfiguration = CreateProductsConfiguration(versionsConfiguration); @@ -214,21 +216,23 @@ public void InferVersionReturnsCorrectVersioningForAllProductApplicabilityProper var productApplicability = CreateProductApplicabilityByName(productApplicabilityEntry); var applicableTo = new ApplicableTo { ProductApplicability = productApplicability }; - var result = inferrer.InferVersion( - repositoryName: "any-repo", - legacyPages: null, - products: null, - applicableTo: applicableTo); + var result = inferrer.InferVersion(repositoryName: "any-repo", legacyPages: null, products: null, applicableTo: applicableTo); result.Should().NotBeNull($"Product {productApplicabilityEntry} should return a valid VersioningSystem via InferVersion"); var resultingProductId = ProductApplicabilityConversion.ProductApplicabilityToProductId(productApplicability); - resultingProductId.Should().NotBeNull($"Product {productApplicabilityEntry} should return a valid product ID via ProductApplicabilityToProductId"); + resultingProductId.Should().NotBeNull( + $"Product {productApplicabilityEntry} should return a valid product ID via ProductApplicabilityToProductId" + ); if (productsConfiguration.Products.TryGetValue(resultingProductId, out var expectedProduct)) { - result.Id.Should().Be(expectedProduct.VersioningSystem!.Id, - $"Product {productApplicabilityEntry} should return versioning system {expectedProduct.VersioningSystem.Id} via InferVersion"); + result.Id + .Should() + .Be( + expectedProduct.VersioningSystem!.Id, + $"Product {productApplicabilityEntry} should return versioning system {expectedProduct.VersioningSystem.Id} via InferVersion" + ); } resultingProductId.Should().Be(targetProductId, $"Product {productApplicabilityEntry} should return '{targetProductId}'"); @@ -248,10 +252,7 @@ public void InferVersionPrioritizesLegacyPagesOverAppliesTo() VersioningSystem = versionsConfiguration.GetVersioningSystem(VersioningSystemId.Ece) }; - var legacyPages = new[] - { - new LegacyUrlMappings.LegacyPageMapping(legacyProduct, "/test/url", "8.0", true) - }; + var legacyPages = new[] { new LegacyUrlMappings.LegacyPageMapping(legacyProduct, "/test/url", "8.0", true) }; var applicableTo = new ApplicableTo { @@ -262,7 +263,8 @@ public void InferVersionPrioritizesLegacyPagesOverAppliesTo() repositoryName: "any-repo", legacyPages: legacyPages, products: null, - applicableTo: applicableTo); + applicableTo: applicableTo + ); result.Id.Should().Be(VersioningSystemId.Ece); } @@ -280,11 +282,7 @@ public void InferVersionPrioritizesProductApplicabilityOverStack() Stack = AppliesCollection.GenerallyAvailable }; - var result = inferrer.InferVersion( - repositoryName: "unknown-repo", - legacyPages: null, - products: null, - applicableTo: applicableTo); + var result = inferrer.InferVersion(repositoryName: "unknown-repo", legacyPages: null, products: null, applicableTo: applicableTo); result.Id.Should().Be(VersioningSystemId.Curator); } @@ -302,11 +300,7 @@ public void InferVersionPrioritizesStackOverDeployment() Deployment = new DeploymentApplicability { Ece = AppliesCollection.GenerallyAvailable } }; - var result = inferrer.InferVersion( - repositoryName: "unknown-repo", - legacyPages: null, - products: null, - applicableTo: applicableTo); + var result = inferrer.InferVersion(repositoryName: "unknown-repo", legacyPages: null, products: null, applicableTo: applicableTo); result.Id.Should().Be(VersioningSystemId.Stack); } @@ -324,11 +318,7 @@ public void InferVersionPrioritizesDeploymentOverServerless() Serverless = new ServerlessProjectApplicability { Elasticsearch = AppliesCollection.GenerallyAvailable } }; - var result = inferrer.InferVersion( - repositoryName: "unknown-repo", - legacyPages: null, - products: null, - applicableTo: applicableTo); + var result = inferrer.InferVersion(repositoryName: "unknown-repo", legacyPages: null, products: null, applicableTo: applicableTo); result.Id.Should().Be(VersioningSystemId.Eck); } @@ -342,8 +332,14 @@ public void InferVersionReturnsServerlessVersioningWhenOnlyServerlessSet() var testCases = new (ServerlessProjectApplicability serverless, VersioningSystemId expectedId)[] { - (new ServerlessProjectApplicability { Elasticsearch = AppliesCollection.GenerallyAvailable }, VersioningSystemId.ElasticsearchProject), - (new ServerlessProjectApplicability { Observability = AppliesCollection.GenerallyAvailable }, VersioningSystemId.ObservabilityProject), + (new ServerlessProjectApplicability + { + Elasticsearch = AppliesCollection.GenerallyAvailable + }, VersioningSystemId.ElasticsearchProject), + (new ServerlessProjectApplicability + { + Observability = AppliesCollection.GenerallyAvailable + }, VersioningSystemId.ObservabilityProject), (new ServerlessProjectApplicability { Security = AppliesCollection.GenerallyAvailable }, VersioningSystemId.SecurityProject), }; @@ -355,7 +351,8 @@ public void InferVersionReturnsServerlessVersioningWhenOnlyServerlessSet() repositoryName: "unknown-repo", legacyPages: null, products: null, - applicableTo: applicableTo); + applicableTo: applicableTo + ); result.Id.Should().Be(expectedId); } @@ -384,7 +381,8 @@ public void InferVersionReturnsDeploymentVersioningForAllDeploymentTypes() repositoryName: "unknown-repo", legacyPages: null, products: null, - applicableTo: applicableTo); + applicableTo: applicableTo + ); result.Id.Should().Be(expectedId); } @@ -397,11 +395,7 @@ public void InferVersionFallsBackToRepositoryNameWhenNoAppliesTo() var productsConfiguration = CreateProductsConfiguration(versionsConfiguration); var inferrer = new ProductVersionInferrerService(productsConfiguration, versionsConfiguration); - var result = inferrer.InferVersion( - repositoryName: "curator", - legacyPages: null, - products: null, - applicableTo: null); + var result = inferrer.InferVersion(repositoryName: "curator", legacyPages: null, products: null, applicableTo: null); result.Id.Should().Be(VersioningSystemId.Curator); } @@ -413,11 +407,7 @@ public void InferVersionFallsBackToStackWhenNoMatchFound() var productsConfiguration = CreateProductsConfiguration(versionsConfiguration); var inferrer = new ProductVersionInferrerService(productsConfiguration, versionsConfiguration); - var result = inferrer.InferVersion( - repositoryName: "unknown-repo", - legacyPages: null, - products: null, - applicableTo: null); + var result = inferrer.InferVersion(repositoryName: "unknown-repo", legacyPages: null, products: null, applicableTo: null); result.Id.Should().Be(VersioningSystemId.Stack); } @@ -446,8 +436,9 @@ public void IsVersionlessReturnsTrueForVersionlessProducts(VersioningSystemId id Base = new SemVersion(VersioningSystem.VersionlessSentinel, 0, 0) }; - versioningSystem.IsVersionless.Should().BeTrue( - $"Versioning system {id} with version {VersioningSystem.VersionlessSentinel} should be marked as versionless"); + versioningSystem.IsVersionless + .Should() + .BeTrue($"Versioning system {id} with version {VersioningSystem.VersionlessSentinel} should be marked as versionless"); } [Theory(DisplayName = "IsVersionless returns false for versioned products")] @@ -465,16 +456,18 @@ public void IsVersionlessReturnsFalseForVersionedProducts(VersioningSystemId id, Base = new SemVersion(major, 0, 0) }; - versioningSystem.IsVersionless.Should().BeFalse( - $"Versioning system {id} with version {major}.{minor}.{patch} should not be marked as versionless"); + versioningSystem.IsVersionless + .Should() + .BeFalse($"Versioning system {id} with version {major}.{minor}.{patch} should not be marked as versionless"); } [Fact(DisplayName = "VersionlessSentinel constant matches versions.yml value")] public void VersionlessSentinelMatchesConfigValue() => // This test ensures the sentinel value matches what's used in config/versions.yml // If this test fails, update VersioningSystem.VersionlessSentinel to match versions.yml - VersioningSystem.VersionlessSentinel.Should().Be(99999, - "VersionlessSentinel should match the value used in config/versions.yml for 'all' versioning system"); + VersioningSystem.VersionlessSentinel + .Should() + .Be(99999, "VersionlessSentinel should match the value used in config/versions.yml for 'all' versioning system"); /// /// These are the versioning system IDs that use the 'all' alias in versions.yml, @@ -507,8 +500,9 @@ public void IsVersionlessCorrectlyIdentifiesAllVersionlessSystemsFromActualConfi { if (versionsConfig.VersioningSystems.TryGetValue(id, out var versioningSystem)) { - versioningSystem.IsVersionless.Should().BeTrue( - $"Versioning system {id} uses 'all' alias in versions.yml and should be marked as versionless"); + versioningSystem.IsVersionless + .Should() + .BeTrue($"Versioning system {id} uses 'all' alias in versions.yml and should be marked as versionless"); } } @@ -517,8 +511,9 @@ public void IsVersionlessCorrectlyIdentifiesAllVersionlessSystemsFromActualConfi { if (!ExpectedVersionlessIds.Contains(id)) { - versioningSystem.IsVersionless.Should().BeFalse( - $"Versioning system {id} has version {versioningSystem.Current} and should NOT be marked as versionless"); + versioningSystem.IsVersionless + .Should() + .BeFalse($"Versioning system {id} has version {versioningSystem.Current} and should NOT be marked as versionless"); } } } @@ -539,14 +534,23 @@ public void AllVersioningSystemsInConfigAreAccountedFor() var versionedSystems = versionsConfig.VersioningSystems.Values.Where(v => !v.IsVersionless).ToList(); // The versionless systems should match our expected list - versionlessSystems.Select(v => v.Id).Should().BeEquivalentTo(ExpectedVersionlessIds, - "The versioning systems marked as versionless should match the expected list from versions.yml"); + versionlessSystems.Select(v => v.Id) + .Should() + .BeEquivalentTo( + ExpectedVersionlessIds, + "The versioning systems marked as versionless should match the expected list from versions.yml" + ); // All versioned systems should have version < 99999 foreach (var system in versionedSystems) { - system.Current.Major.Should().BeLessThan(VersioningSystem.VersionlessSentinel, - $"Versioned system {system.Id} should have major version less than {VersioningSystem.VersionlessSentinel}"); + system.Current + .Major + .Should() + .BeLessThan( + VersioningSystem.VersionlessSentinel, + $"Versioned system {system.Id} should have major version less than {VersioningSystem.VersionlessSentinel}" + ); } } } diff --git a/tests/Elastic.Documentation.Integrations.Tests/S3/S3EtagCalculatorTests.cs b/tests/Elastic.Documentation.Integrations.Tests/S3/S3EtagCalculatorTests.cs index 27c0648a32..3f06d11924 100644 --- a/tests/Elastic.Documentation.Integrations.Tests/S3/S3EtagCalculatorTests.cs +++ b/tests/Elastic.Documentation.Integrations.Tests/S3/S3EtagCalculatorTests.cs @@ -16,11 +16,9 @@ public class S3EtagCalculatorTests private readonly MockFileSystem _fileSystem = new(); private readonly S3EtagCalculator _calculator; - public S3EtagCalculatorTests() => - _calculator = new S3EtagCalculator(NullLoggerFactory.Instance, _fileSystem); + public S3EtagCalculatorTests() => _calculator = new S3EtagCalculator(NullLoggerFactory.Instance, _fileSystem); - private string TempPath(string name) => - _fileSystem.Path.Join(_fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), name); + private string TempPath(string name) => _fileSystem.Path.Join(_fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), name); [Fact] [SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")] diff --git a/tests/Elastic.Documentation.Integrations.Tests/S3/S3IncrementalUploaderTests.cs b/tests/Elastic.Documentation.Integrations.Tests/S3/S3IncrementalUploaderTests.cs index dadb3244f3..2c3df9bd78 100644 --- a/tests/Elastic.Documentation.Integrations.Tests/S3/S3IncrementalUploaderTests.cs +++ b/tests/Elastic.Documentation.Integrations.Tests/S3/S3IncrementalUploaderTests.cs @@ -25,8 +25,7 @@ public class S3IncrementalUploaderTests private S3IncrementalUploader CreateUploader() => new(NullLoggerFactory.Instance, _s3Client, _fileSystem, new S3EtagCalculator(NullLoggerFactory.Instance, _fileSystem), BucketName); - private string UniquePath(string name) => - _fileSystem.Path.Join(_fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), name); + private string UniquePath(string name) => _fileSystem.Path.Join(_fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), name); [Fact] public async Task Upload_NewFile_UploadsSuccessfully() @@ -34,11 +33,12 @@ public async Task Upload_NewFile_UploadsSuccessfully() var path = UniquePath("entry.yaml"); _fileSystem.AddFile(path, new MockFileData("new changelog"u8.ToArray())); - A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)) - .Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); + A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)).Throws(new AmazonS3Exception( + "Not Found" + ) + { StatusCode = HttpStatusCode.NotFound }); - A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) - .Returns(new PutObjectResponse()); + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)).Returns(new PutObjectResponse()); var uploader = CreateUploader(); var ct = TestContext.Current.CancellationToken; @@ -48,10 +48,13 @@ public async Task Upload_NewFile_UploadsSuccessfully() result.Skipped.Should().Be(0); result.Failed.Should().Be(0); - A.CallTo(() => _s3Client.PutObjectAsync( - A.That.Matches(r => r.Key == "elasticsearch/changelog/entry.yaml" && r.BucketName == BucketName), - A._ - )).MustHaveHappenedOnceExactly(); + A.CallTo( + () => + _s3Client.PutObjectAsync( + A.That.Matches(r => r.Key == "elasticsearch/changelog/entry.yaml" && r.BucketName == BucketName), + A._ + ) + ).MustHaveHappenedOnceExactly(); } [Fact] @@ -62,8 +65,10 @@ public async Task Upload_UnchangedFile_SkipsUpload() _fileSystem.AddFile(path, new MockFileData(content)); var localEtag = Convert.ToHexStringLower(MD5.HashData(content)); - A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)) - .Returns(new GetObjectMetadataResponse { ETag = $"\"{localEtag}\"" }); + A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)).Returns(new GetObjectMetadataResponse + { + ETag = $"\"{localEtag}\"" + }); var uploader = CreateUploader(); var ct = TestContext.Current.CancellationToken; @@ -73,8 +78,7 @@ public async Task Upload_UnchangedFile_SkipsUpload() result.Skipped.Should().Be(1); result.Failed.Should().Be(0); - A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) - .MustNotHaveHappened(); + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)).MustNotHaveHappened(); } [Fact] @@ -85,11 +89,12 @@ public async Task Upload_UnchangedFile_WithSkipEtagCheck_Uploads() _fileSystem.AddFile(path, new MockFileData(content)); var localEtag = Convert.ToHexStringLower(MD5.HashData(content)); - A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)) - .Returns(new GetObjectMetadataResponse { ETag = $"\"{localEtag}\"" }); + A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)).Returns(new GetObjectMetadataResponse + { + ETag = $"\"{localEtag}\"" + }); - A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) - .Returns(new PutObjectResponse()); + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)).Returns(new PutObjectResponse()); var uploader = CreateUploader(); var ct = TestContext.Current.CancellationToken; @@ -99,13 +104,15 @@ public async Task Upload_UnchangedFile_WithSkipEtagCheck_Uploads() result.Skipped.Should().Be(0); result.Failed.Should().Be(0); - A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)) - .MustNotHaveHappened(); + A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)).MustNotHaveHappened(); - A.CallTo(() => _s3Client.PutObjectAsync( - A.That.Matches(r => r.Key == "kibana/changelog/entry.yaml" && r.BucketName == BucketName), - A._ - )).MustHaveHappenedOnceExactly(); + A.CallTo( + () => + _s3Client.PutObjectAsync( + A.That.Matches(r => r.Key == "kibana/changelog/entry.yaml" && r.BucketName == BucketName), + A._ + ) + ).MustHaveHappenedOnceExactly(); } [Fact] @@ -114,11 +121,12 @@ public async Task Upload_ChangedFile_UploadsNewVersion() var path = UniquePath("entry.yaml"); _fileSystem.AddFile(path, new MockFileData("updated changelog"u8.ToArray())); - A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)) - .Returns(new GetObjectMetadataResponse { ETag = "\"stale-etag\"" }); + A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)).Returns(new GetObjectMetadataResponse + { + ETag = "\"stale-etag\"" + }); - A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) - .Returns(new PutObjectResponse()); + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)).Returns(new PutObjectResponse()); var uploader = CreateUploader(); var ct = TestContext.Current.CancellationToken; @@ -135,11 +143,15 @@ public async Task Upload_S3PutFails_CountsAsFailure() var path = UniquePath("entry.yaml"); _fileSystem.AddFile(path, new MockFileData("content"u8.ToArray())); - A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)) - .Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); + A.CallTo(() => _s3Client.GetObjectMetadataAsync(A._, A._)).Throws(new AmazonS3Exception( + "Not Found" + ) + { StatusCode = HttpStatusCode.NotFound }); - A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) - .Throws(new AmazonS3Exception("Access Denied") { StatusCode = HttpStatusCode.Forbidden }); + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)).Throws(new AmazonS3Exception("Access Denied") + { + StatusCode = HttpStatusCode.Forbidden + }); var uploader = CreateUploader(); var ct = TestContext.Current.CancellationToken; @@ -159,25 +171,31 @@ public async Task Upload_MixedTargets_ReportsCorrectCounts() _fileSystem.AddFile(unchangedPath, new MockFileData("unchanged"u8.ToArray())); var unchangedEtag = Convert.ToHexStringLower(MD5.HashData("unchanged"u8.ToArray())); - A.CallTo(() => _s3Client.GetObjectMetadataAsync( - A.That.Matches(r => r.Key == "es/changelog/new.yaml"), - A._ - )).Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); + A.CallTo( + () => + _s3Client.GetObjectMetadataAsync( + A.That.Matches(r => r.Key == "es/changelog/new.yaml"), + A._ + ) + ).Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); - A.CallTo(() => _s3Client.GetObjectMetadataAsync( - A.That.Matches(r => r.Key == "es/changelog/unchanged.yaml"), - A._ - )).Returns(new GetObjectMetadataResponse { ETag = $"\"{unchangedEtag}\"" }); + A.CallTo( + () => + _s3Client.GetObjectMetadataAsync( + A.That.Matches(r => r.Key == "es/changelog/unchanged.yaml"), + A._ + ) + ).Returns(new GetObjectMetadataResponse { ETag = $"\"{unchangedEtag}\"" }); - A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) - .Returns(new PutObjectResponse()); + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)).Returns(new PutObjectResponse()); var uploader = CreateUploader(); var ct = TestContext.Current.CancellationToken; - var result = await uploader.Upload([ - new UploadTarget(newPath, "es/changelog/new.yaml"), - new UploadTarget(unchangedPath, "es/changelog/unchanged.yaml") - ], ctx: ct); + var result = + await uploader.Upload( + [new UploadTarget(newPath, "es/changelog/new.yaml"), new UploadTarget(unchangedPath, "es/changelog/unchanged.yaml")], + ctx: ct + ); result.Uploaded.Should().Be(1); result.Skipped.Should().Be(1); diff --git a/tests/Elastic.Documentation.LegacyDocs.Tests/LegacyPageLookupTests.cs b/tests/Elastic.Documentation.LegacyDocs.Tests/LegacyPageLookupTests.cs index 6ac420f77b..4c6eeadd04 100644 --- a/tests/Elastic.Documentation.LegacyDocs.Tests/LegacyPageLookupTests.cs +++ b/tests/Elastic.Documentation.LegacyDocs.Tests/LegacyPageLookupTests.cs @@ -37,9 +37,7 @@ public void TestVersions() }; foreach (var (version, value) in expected) { - var result = legacyPageChecker.PathExists( - $"/guide/en/elasticsearch/reference/{version}/elasticsearch-intro-what-is-es.html" - ); + var result = legacyPageChecker.PathExists($"/guide/en/elasticsearch/reference/{version}/elasticsearch-intro-what-is-es.html"); _ = result.Should().Be(value, $"Expected {version} to be {value}"); } } diff --git a/tests/Elastic.Documentation.OpenApiIndex.Tests/CloudFrontCacheInvalidatorTests.cs b/tests/Elastic.Documentation.OpenApiIndex.Tests/CloudFrontCacheInvalidatorTests.cs index 7acd0300d9..7d3e5740ab 100644 --- a/tests/Elastic.Documentation.OpenApiIndex.Tests/CloudFrontCacheInvalidatorTests.cs +++ b/tests/Elastic.Documentation.OpenApiIndex.Tests/CloudFrontCacheInvalidatorTests.cs @@ -45,15 +45,15 @@ public async Task InvalidateAsync_EmptyPaths_DoesNotCallCloudFront() await invalidator.InvalidateAsync([], "request-id-1", TestContext.Current.CancellationToken); - A.CallTo(() => _cloudFrontClient.CreateInvalidationAsync(A._, A._)) - .MustNotHaveHappened(); + A.CallTo(() => _cloudFrontClient.CreateInvalidationAsync(A._, A._)).MustNotHaveHappened(); } [Fact] public async Task InvalidateAsync_CloudFrontFailure_PropagatesException() { - A.CallTo(() => _cloudFrontClient.CreateInvalidationAsync(A._, A._)) - .Throws(new AmazonCloudFrontException("Access denied")); + A.CallTo(() => _cloudFrontClient.CreateInvalidationAsync(A._, A._)).Throws( + new AmazonCloudFrontException("Access denied") + ); var act = () => CreateInvalidator().InvalidateAsync(["/index.json"], "request-id-1", TestContext.Current.CancellationToken); diff --git a/tests/Elastic.Documentation.OpenApiIndex.Tests/OpenApiInvalidationPathsTests.cs b/tests/Elastic.Documentation.OpenApiIndex.Tests/OpenApiInvalidationPathsTests.cs index 7c66bf5d8f..ab0659937e 100644 --- a/tests/Elastic.Documentation.OpenApiIndex.Tests/OpenApiInvalidationPathsTests.cs +++ b/tests/Elastic.Documentation.OpenApiIndex.Tests/OpenApiInvalidationPathsTests.cs @@ -21,21 +21,13 @@ public void Build_AddsLeadingSlashForEachObjectKey() { var paths = OpenApiInvalidationPaths.Build(["elastic/elasticsearch/8.16/openapi.json"]); - paths.Should().BeEquivalentTo( - [ - "/index.json", - "/elastic/elasticsearch/8.16/openapi.json" - ]); + paths.Should().BeEquivalentTo(["/index.json", "/elastic/elasticsearch/8.16/openapi.json"]); } [Fact] public void Build_DeduplicatesRepeatedKeys() { - var paths = OpenApiInvalidationPaths.Build( - [ - "elastic/elasticsearch/8.16/openapi.json", - "elastic/elasticsearch/8.16/openapi.json" - ]); + var paths = OpenApiInvalidationPaths.Build(["elastic/elasticsearch/8.16/openapi.json", "elastic/elasticsearch/8.16/openapi.json"]); paths.Should().HaveCount(2); } diff --git a/tests/Elastic.Documentation.OpenApiIndex.Tests/VersionIndexBuilderTests.cs b/tests/Elastic.Documentation.OpenApiIndex.Tests/VersionIndexBuilderTests.cs index fb1953f924..fac5bc65df 100644 --- a/tests/Elastic.Documentation.OpenApiIndex.Tests/VersionIndexBuilderTests.cs +++ b/tests/Elastic.Documentation.OpenApiIndex.Tests/VersionIndexBuilderTests.cs @@ -20,10 +20,7 @@ public void Build_SingleVersion_CreatesMajorEntry() [Fact] public void Build_NewMajorAddedToExistingIndex_CreatesSeparateEntry() { - var index = VersionIndexBuilder.Build([ - "elastic/elasticsearch/8.16/openapi.json", - "elastic/elasticsearch/9.0/openapi.json" - ]).Index; + var index = VersionIndexBuilder.Build(["elastic/elasticsearch/8.16/openapi.json", "elastic/elasticsearch/9.0/openapi.json"]).Index; var byMajor = index["elastic/elasticsearch"]["openapi.json"]; byMajor.Should().HaveCount(2); @@ -34,10 +31,7 @@ public void Build_NewMajorAddedToExistingIndex_CreatesSeparateEntry() [Fact] public void Build_MinorBumpWithinExistingMajor_KeepsHighestMinor() { - var index = VersionIndexBuilder.Build([ - "elastic/elasticsearch/8.16/openapi.json", - "elastic/elasticsearch/8.17/openapi.json" - ]).Index; + var index = VersionIndexBuilder.Build(["elastic/elasticsearch/8.16/openapi.json", "elastic/elasticsearch/8.17/openapi.json"]).Index; index["elastic/elasticsearch"]["openapi.json"]["8"].Version.Should().Be("8.17"); } @@ -45,10 +39,7 @@ public void Build_MinorBumpWithinExistingMajor_KeepsHighestMinor() [Fact] public void Build_OutOfOrderArrival_HigherMinorListedBeforeLower_KeepsHighestMinor() { - var index = VersionIndexBuilder.Build([ - "elastic/elasticsearch/8.17/openapi.json", - "elastic/elasticsearch/8.16/openapi.json" - ]).Index; + var index = VersionIndexBuilder.Build(["elastic/elasticsearch/8.17/openapi.json", "elastic/elasticsearch/8.16/openapi.json"]).Index; index["elastic/elasticsearch"]["openapi.json"]["8"].Version.Should().Be("8.17"); } @@ -64,10 +55,7 @@ public void Build_MainVersion_CreatesMainEntry() [Fact] public void Build_MainAndReleaseVersions_KeepsBothSeparately() { - var index = VersionIndexBuilder.Build([ - "elastic/elasticsearch/main/openapi.json", - "elastic/elasticsearch/8.16/openapi.json" - ]).Index; + var index = VersionIndexBuilder.Build(["elastic/elasticsearch/main/openapi.json", "elastic/elasticsearch/8.16/openapi.json"]).Index; var byMajor = index["elastic/elasticsearch"]["openapi.json"]; byMajor.Should().HaveCount(2); @@ -79,10 +67,7 @@ public void Build_MainAndReleaseVersions_KeepsBothSeparately() public void Build_MultipleSpecFilesInSameRepo_IndexesEachIndependently() { // Two spec files from one repo may share a version: they are separate objects in the bucket. - var index = VersionIndexBuilder.Build([ - "elastic/kibana/8.16/kibana.json", - "elastic/kibana/8.16/kibana-serverless.json" - ]).Index; + var index = VersionIndexBuilder.Build(["elastic/kibana/8.16/kibana.json", "elastic/kibana/8.16/kibana-serverless.json"]).Index; var byFile = index["elastic/kibana"]; byFile.Should().HaveCount(2); @@ -93,10 +78,7 @@ public void Build_MultipleSpecFilesInSameRepo_IndexesEachIndependently() [Fact] public void Build_MultipleRepos_KeepsSeparateEntriesPerRepo() { - var index = VersionIndexBuilder.Build([ - "elastic/elasticsearch/8.16/openapi.json", - "elastic/kibana/8.16/kibana.json" - ]).Index; + var index = VersionIndexBuilder.Build(["elastic/elasticsearch/8.16/openapi.json", "elastic/kibana/8.16/kibana.json"]).Index; index.Should().HaveCount(2); index["elastic/elasticsearch"]["openapi.json"]["8"].Version.Should().Be("8.16"); @@ -108,15 +90,25 @@ public void Build_MultipleRepos_KeepsSeparateEntriesPerRepo() [Theory] [InlineData("elastic/elasticsearch/openapi.json")] // missing version segment + [InlineData("elastic/elasticsearch/8.16/nested/openapi.json")] // too many segments + [InlineData("elastic//8.16/openapi.json")] // empty repo segment + [InlineData("elastic/elasticsearch/8.16/")] // empty file segment + [InlineData("elastic/elasticsearch/master/openapi.json")] // not "main" or . + [InlineData("elastic/elasticsearch/8/openapi.json")] // missing minor + [InlineData("elastic/elasticsearch/8./openapi.json")] // missing minor after the dot + [InlineData("elastic/elasticsearch/8.x/openapi.json")] // non-numeric minor + [InlineData("elastic/elasticsearch/.16/openapi.json")] // missing major + [InlineData("elastic/elasticsearch/+8.16/openapi.json")] // signed major + public void Build_KeyOfUnexpectedShape_IsReportedAndSkipped(string key) { var (index, invalidKeys) = VersionIndexBuilder.Build([key]); @@ -128,11 +120,12 @@ public void Build_KeyOfUnexpectedShape_IsReportedAndSkipped(string key) [Fact] public void Build_MixOfValidAndInvalidKeys_IndexesValidAndReportsInvalidOnly() { - var (index, invalidKeys) = VersionIndexBuilder.Build([ - "elastic/elasticsearch/8.16/openapi.json", - "not-a-valid-key", - "elastic/elasticsearch/master/openapi.json" - ]); + var (index, invalidKeys) = + VersionIndexBuilder.Build([ + "elastic/elasticsearch/8.16/openapi.json", + "not-a-valid-key", + "elastic/elasticsearch/master/openapi.json" + ]); index["elastic/elasticsearch"]["openapi.json"]["8"].Version.Should().Be("8.16"); invalidKeys.Should().BeEquivalentTo(["not-a-valid-key", "elastic/elasticsearch/master/openapi.json"]); diff --git a/tests/Elastic.Documentation.OpenApiIndex.Tests/VersionIndexPublisherTests.cs b/tests/Elastic.Documentation.OpenApiIndex.Tests/VersionIndexPublisherTests.cs index 03c4482b3d..2ede443af4 100644 --- a/tests/Elastic.Documentation.OpenApiIndex.Tests/VersionIndexPublisherTests.cs +++ b/tests/Elastic.Documentation.OpenApiIndex.Tests/VersionIndexPublisherTests.cs @@ -16,7 +16,7 @@ public class VersionIndexPublisherTests private const string BucketName = "test-bucket"; /// The exact serialized index for a bucket holding only elastic/elasticsearch/8.16/openapi.json. - private const string Index816Json = /*lang=json,strict*/ """{"elastic/elasticsearch":{"openapi.json":{"8":{"version":"8.16"}}}}"""; + private const string Index816Json = /*lang=json,strict*/ """{"elastic/elasticsearch":{"openapi.json":{"8":{"version":"8.16"}}}}"""; private readonly IAmazonS3 _s3Client = A.Fake(); private readonly List _puts = []; @@ -29,20 +29,24 @@ public VersionIndexPublisherTests() => private VersionIndexPublisher CreatePublisher() => new(_s3Client, BucketName); private void GivenBucketContains(params string[] keys) => - A.CallTo(() => _s3Client.ListObjectsV2Async(A._, A._)) - .Returns(new ListObjectsV2Response - { - S3Objects = [.. keys.Select(k => new S3Object { Key = k })], - IsTruncated = false - }); + A.CallTo(() => _s3Client.ListObjectsV2Async(A._, A._)).Returns(new ListObjectsV2Response + { + S3Objects = [.. keys.Select(k => new S3Object { Key = k })], + IsTruncated = false + }); private void GivenNoPublishedIndex() => - A.CallTo(() => _s3Client.GetObjectAsync(A._, A._)) - .Throws(new AmazonS3Exception("Not Found") { StatusCode = HttpStatusCode.NotFound }); + A.CallTo(() => _s3Client.GetObjectAsync(A._, A._)).Throws(new AmazonS3Exception("Not Found") + { + StatusCode = HttpStatusCode.NotFound + }); private void GivenPublishedIndex(string body, string etag = "\"etag-1\"") => - A.CallTo(() => _s3Client.GetObjectAsync(A._, A._)) - .Returns(new GetObjectResponse { ETag = etag, ResponseStream = new MemoryStream(Encoding.UTF8.GetBytes(body)) }); + A.CallTo(() => _s3Client.GetObjectAsync(A._, A._)).Returns(new GetObjectResponse + { + ETag = etag, + ResponseStream = new MemoryStream(Encoding.UTF8.GetBytes(body)) + }); [Fact] public async Task RefreshAsync_NoPublishedIndex_CreatesWithIfNoneMatch() @@ -92,8 +96,10 @@ public async Task RefreshAsync_ConditionalWriteConflict_ThrowsWithoutRetrying() // in process — it throws, so the handler returns the message to the queue and SQS redelivers it. GivenBucketContains("elastic/elasticsearch/8.16/openapi.json"); GivenNoPublishedIndex(); - A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)) - .Throws(new AmazonS3Exception("Precondition Failed") { StatusCode = HttpStatusCode.PreconditionFailed }); + A.CallTo(() => _s3Client.PutObjectAsync(A._, A._)).Throws(new AmazonS3Exception("Precondition Failed") + { + StatusCode = HttpStatusCode.PreconditionFailed + }); var act = () => CreatePublisher().RefreshAsync(TestContext.Current.CancellationToken); @@ -127,19 +133,21 @@ public async Task RefreshAsync_IndexKeyItselfInListing_IsExcludedFromRebuild() [Fact] public async Task RefreshAsync_PaginatedListing_CombinesAllPages() { - A.CallTo(() => _s3Client.ListObjectsV2Async(A.That.Matches(r => r.ContinuationToken == null), A._)) - .Returns(new ListObjectsV2Response - { - S3Objects = [new S3Object { Key = "elastic/elasticsearch/8.16/openapi.json" }], - IsTruncated = true, - NextContinuationToken = "page-2" - }); - A.CallTo(() => _s3Client.ListObjectsV2Async(A.That.Matches(r => r.ContinuationToken == "page-2"), A._)) - .Returns(new ListObjectsV2Response - { - S3Objects = [new S3Object { Key = "elastic/kibana/8.16/kibana.json" }], - IsTruncated = false - }); + A.CallTo( + () => _s3Client.ListObjectsV2Async(A.That.Matches(r => r.ContinuationToken == null), A._) + ).Returns(new ListObjectsV2Response + { + S3Objects = [new S3Object { Key = "elastic/elasticsearch/8.16/openapi.json" }], + IsTruncated = true, + NextContinuationToken = "page-2" + }); + A.CallTo( + () => _s3Client.ListObjectsV2Async(A.That.Matches(r => r.ContinuationToken == "page-2"), A._) + ).Returns(new ListObjectsV2Response + { + S3Objects = [new S3Object { Key = "elastic/kibana/8.16/kibana.json" }], + IsTruncated = false + }); GivenNoPublishedIndex(); await CreatePublisher().RefreshAsync(TestContext.Current.CancellationToken); diff --git a/tests/Elastic.LegacyDocs.Migration.Tests/ChunkerTests.cs b/tests/Elastic.LegacyDocs.Migration.Tests/ChunkerTests.cs index 431a811838..d09c0dbc27 100644 --- a/tests/Elastic.LegacyDocs.Migration.Tests/ChunkerTests.cs +++ b/tests/Elastic.LegacyDocs.Migration.Tests/ChunkerTests.cs @@ -15,16 +15,16 @@ namespace Elastic.LegacyDocs.Migration.Tests; /// public class ChunkerTests { - private static MarkdownEmitter Emitter() => - new(new MarkdownEmitterOptions { BookPrefix = "test", Version = "1.0" }); + private static MarkdownEmitter Emitter() => new(new MarkdownEmitterOptions { BookPrefix = "test", Version = "1.0" }); - private static Elastic.LegacyDocs.Migration.Asciidoc.Ast.AsciidocDocument Parse(string content, Dictionary? files = null) + private static Elastic.LegacyDocs.Migration.Asciidoc.Ast.AsciidocDocument Parse( + string content, + Dictionary? files = null + ) { var opts = new AsciidocParserOptions { - FileReader = files is not null - ? path => files.TryGetValue(path, out var c) ? c : null - : null + FileReader = files is not null ? path => files.TryGetValue(path, out var c) ? c : null : null }; return new AsciidocParser(opts).Parse(content, "/base"); } @@ -61,12 +61,14 @@ public void DiscreteSection_IsNeverChunked() // == Index and search… (Level 1 ≤ 2 → child page) var files = new Dictionary { - ["/base/index.adoc"] = """ + ["/base/index.adoc"] = + """ = Elasticsearch Guide include::quickstart.adoc[] """, - ["/base/quickstart.adoc"] = """ + ["/base/quickstart.adoc"] = + """ [[quickstart]] = Quick starts @@ -113,13 +115,15 @@ public void Section_DeeperThanChunkLevel_StaysOnParentPage() include::setup.adoc[] """, - ["/base/setup.adoc"] = """ + ["/base/setup.adoc"] = + """ [[setup]] == Installing Elasticsearch include::targz.adoc[] """, - ["/base/targz.adoc"] = """ + ["/base/targz.adoc"] = + """ [[targz]] === Install from archive on Linux @@ -140,7 +144,7 @@ Next steps text. setup.Children.Should().HaveCount(1); var targz = setup.Children[0]; targz.Slug.Should().Be("targz"); - targz.Children.Should().BeEmpty(); // no further child pages + targz.Children.Should().BeEmpty(); // no further child pages targz.MarkdownContent.Should().Contain("Intro text"); targz.MarkdownContent.Should().Contain("## Next steps"); // Level 3, rebased: effective=3-2=1 → ## } @@ -158,7 +162,8 @@ public void TitleAbbrev_EmitsNavigationTitleFrontmatter() include::getting-started.adoc[] """, - ["/base/getting-started.adoc"] = """ + ["/base/getting-started.adoc"] = + """ [[getting-started]] == Index and search data using Elasticsearch APIs @@ -187,7 +192,8 @@ public void TitleAbbrev_MatchingTitle_DoesNotEmitFrontmatter() include::mypage.adoc[] """, - ["/base/mypage.adoc"] = """ + ["/base/mypage.adoc"] = + """ [[mypage]] == My Page @@ -216,7 +222,8 @@ public void IdLessSection_UsesAutoId() include::install.adoc[] """, - ["/base/install.adoc"] = """ + ["/base/install.adoc"] = + """ == Install from Archive on Linux/MacOS Content. @@ -236,7 +243,8 @@ public void DuplicateSlug_IsSuffixed() // Two included files with the same section id would collide; the second gets _2. var files = new Dictionary { - ["/base/index.adoc"] = """ + ["/base/index.adoc"] = + """ = Book include::a.adoc[] @@ -283,24 +291,28 @@ public void CrossInclude_Level1FollowingLevel0_NestedAsChildren() include::migration/index.adoc[] """, - ["/base/migration/index.adoc"] = """ + ["/base/migration/index.adoc"] = + """ include::intro.adoc[] include::migrate-1.adoc[] include::migrate-2.adoc[] """, - ["/base/migration/intro.adoc"] = """ + ["/base/migration/intro.adoc"] = + """ [[migration-guide]] = Migration guide Intro text. """, - ["/base/migration/migrate-1.adoc"] = """ + ["/base/migration/migrate-1.adoc"] = + """ [[migrating-1]] == Migrating to 1.0 Migration 1 content. """, - ["/base/migration/migrate-2.adoc"] = """ + ["/base/migration/migrate-2.adoc"] = + """ [[migrating-2]] == Migrating to 2.0 @@ -332,11 +344,7 @@ public void WriteTocYaml_NestedChildren_IndentsCorrectly() new() { File = "quickstart.md", - Children = - [ - new TocEntry { File = "getting-started.md" }, - new TocEntry { File = "full-text.md" } - ] + Children = [new TocEntry { File = "getting-started.md" }, new TocEntry { File = "full-text.md" }] }, }; diff --git a/tests/Elastic.LegacyDocs.Migration.Tests/EmitterTests.cs b/tests/Elastic.LegacyDocs.Migration.Tests/EmitterTests.cs index 66ba5804b4..f92f42838e 100644 --- a/tests/Elastic.LegacyDocs.Migration.Tests/EmitterTests.cs +++ b/tests/Elastic.LegacyDocs.Migration.Tests/EmitterTests.cs @@ -83,7 +83,8 @@ public void CrossRef_WithBacktickInText_IsEmittedCorrectly() public void CrossRef_InMultiLineParagraph_WithBacktick_IsEmittedCorrectly() { // Multi-line paragraph where xref with backtick in text is on one line - var asciidoc = "= T\n\nIt can be combined\nwith token filters like <> to\nnormalise the analysed terms.\n"; + var asciidoc = + "= T\n\nIt can be combined\nwith token filters like <> to\nnormalise the analysed terms.\n"; var md = Emit(asciidoc); md.Should().NotContain("<<"); md.Should().Contain("[`lowercase`]"); @@ -93,7 +94,8 @@ public void CrossRef_InMultiLineParagraph_WithBacktick_IsEmittedCorrectly() public void CrossRef_AfterDLItemWithBlankLineSeparator_IsEmittedCorrectly() { // Description list where term is followed by blank line, then description paragraph containing xref - var asciidoc = "= T\n\n<>::\n\nIt can be combined with token filters like <> to\nnormalise the analysed terms.\n"; + var asciidoc = + "= T\n\n<>::\n\nIt can be combined with token filters like <> to\nnormalise the analysed terms.\n"; var md = Emit(asciidoc); md.Should().NotContain("<<"); md.Should().Contain("[`lowercase`]"); @@ -103,7 +105,8 @@ public void CrossRef_AfterDLItemWithBlankLineSeparator_IsEmittedCorrectly() public void CrossRef_InMultiLineParagraph_WithCurlyQuotes_IsEmittedCorrectly() { // Paragraph using AsciiDoc curly-quotes ``...'' before the xref line - var asciidoc = "= T\n\nIn order to use scrolling, the initial search request should specify the\n`scroll` parameter in the query string, which tells Elasticsearch how long it\nshould keep the ``search context'' alive (see <>), eg `?scroll=1m`.\n"; + var asciidoc = + "= T\n\nIn order to use scrolling, the initial search request should specify the\n`scroll` parameter in the query string, which tells Elasticsearch how long it\nshould keep the ``search context'' alive (see <>), eg `?scroll=1m`.\n"; var md = Emit(asciidoc); md.Should().NotContain("<<"); md.Should().Contain("[scroll-search-context]"); @@ -230,7 +233,7 @@ public void CodeBlock_TrailingSpaceOnClosingDelimiter_StillClosesBlock() md.Should().Contain("```json"); md.Should().Contain(/*lang=json,strict*/ "{\"hello\":\"world\"}"); // If the block closes, Next section becomes a real heading; if not, it's inside code block - md.Should().NotContain("==== Next section"); // raw AsciiDoc must not appear + md.Should().NotContain("==== Next section"); // raw AsciiDoc must not appear } [Fact] @@ -250,7 +253,8 @@ public void Ifeval_ContentAfterBlock_NotLeaked_WhenConditionFalse() public void CodeBlock_LongDashDelimiter_IsTreatedAsCodeFence() { // Watcher 2.4 docs use 50-dash lines as code block delimiters (valid AsciiDoc: 4+ dashes). - var adoc = "= T\n\n[source,js]\n--------------------------------------------------\n\"input\": {}\n--------------------------------------------------\n\nNormal paragraph.\n"; + var adoc = + "= T\n\n[source,js]\n--------------------------------------------------\n\"input\": {}\n--------------------------------------------------\n\nNormal paragraph.\n"; var md = Emit(adoc); md.Should().Contain("```"); md.Should().Contain("\"input\": {}"); @@ -262,7 +266,8 @@ public void CodeBlock_LongDashDelimiter_IsTreatedAsCodeFence() public void CodeBlock_LongDashDelimiter_InNestedSection_IsTreatedAsCodeFence() { // Watcher 2.4 docs: code blocks with 50-dash delimiters inside nested sections (== > === > ====). - var adoc = string.Join("\n", + var adoc = string.Join( + "\n", "= Doc", "", "[[top]]", @@ -282,7 +287,8 @@ public void CodeBlock_LongDashDelimiter_InNestedSection_IsTreatedAsCodeFence() "--------------------------------------------------", "", "Normal paragraph.", - ""); + "" + ); var md = Emit(adoc); md.Should().Contain("```"); md.Should().Contain("\"key\": \"value\""); diff --git a/tests/Elastic.LegacyDocs.Migration.Tests/ParserTests.cs b/tests/Elastic.LegacyDocs.Migration.Tests/ParserTests.cs index 437c7ca598..df08e91544 100644 --- a/tests/Elastic.LegacyDocs.Migration.Tests/ParserTests.cs +++ b/tests/Elastic.LegacyDocs.Migration.Tests/ParserTests.cs @@ -16,8 +16,7 @@ public class ParserTests /// content direct doc.Children; `= Title` (Level 0) creates a Level-0 SectionNode wrapper. ///
private static AsciidocDocument Parse(string content, Dictionary? attrs = null) => - new AsciidocParser(new AsciidocParserOptions { Attributes = attrs ?? [] }) - .Parse(content, ""); + new AsciidocParser(new AsciidocParserOptions { Attributes = attrs ?? [] }).Parse(content, ""); /// Recursively finds the first node of type T in the document tree. private static T? FindFirst(IEnumerable nodes) where T : class @@ -27,17 +26,28 @@ private static AsciidocDocument Parse(string content, Dictionary if (node is T found) return found; if (node is SectionNode s) - { var r = FindFirst(s.Children); if (r is not null) return r; } + { + var r = FindFirst(s.Children); + if (r is not null) + return r; + } if (node is OpenBlockNode o) - { var r = FindFirst(o.Children); if (r is not null) return r; } + { + var r = FindFirst(o.Children); + if (r is not null) + return r; + } if (node is AdmonitionNode a) - { var r = FindFirst(a.Children); if (r is not null) return r; } + { + var r = FindFirst(a.Children); + if (r is not null) + return r; + } } return null; } - private static T? FindFirst(AsciidocDocument doc) where T : class => - FindFirst(doc.Children); + private static T? FindFirst(AsciidocDocument doc) where T : class => FindFirst(doc.Children); // ── Step 1: -- open blocks ──────────────────────────────────────────────── @@ -115,11 +125,7 @@ public void SetAttribute_ProductNameKeys_AreNotStored() [Fact] public void AttributeResolution_SeedAttributes_AreAvailable() { - var attrs = new Dictionary - { - ["branch"] = "8.19", - ["docs-root"] = "/work/docs-repo" - }; + var attrs = new Dictionary { ["branch"] = "8.19", ["docs-root"] = "/work/docs-repo" }; var content = "Branch is {branch}\n"; var parser = new AsciidocParser(new AsciidocParserOptions { Attributes = attrs }); var doc = parser.Parse(content, ""); @@ -167,11 +173,17 @@ public void AdmonitionParagraph_MultiLine_CollectsAllContent() // All three lines should be inside the admonition's paragraph var para = admonition.Children.OfType().FirstOrDefault(); para.Should().NotBeNull(); - var allText = string.Join("", para.Inlines.Select(i => i switch - { - TextInline t => t.Text, - _ => "" - })); + var allText = string.Join( + "", + para.Inlines.Select( + i => + i switch + { + TextInline t => t.Text, + _ => "" + } + ) + ); allText.Should().Contain("First line"); allText.Should().Contain("Second line"); allText.Should().Contain("Third line"); @@ -196,15 +208,9 @@ public void Lexer_VerbatimBlock_TrailingSpaceOnClosingDelimiter_ClosesBlock() [Fact] public void IncludeDirective_InsideDelimitedBlock_IsResolvedWhenFileExists() { - var files = new Dictionary - { - ["/base/inner.adoc"] = "included content" - }; + var files = new Dictionary { ["/base/inner.adoc"] = "included content" }; var content = "[NOTE]\n====\ninclude::inner.adoc[]\n====\n"; - var parser = new AsciidocParser(new AsciidocParserOptions - { - FileReader = path => files.TryGetValue(path, out var c) ? c : null - }); + var parser = new AsciidocParser(new AsciidocParserOptions { FileReader = path => files.TryGetValue(path, out var c) ? c : null }); var doc = parser.Parse(content, "/base"); var admonition = doc.Children.OfType().FirstOrDefault(); admonition.Should().NotBeNull(); @@ -218,7 +224,8 @@ public void Parse_file_starting_with_level1_section_promotes_it_to_doc_title() // The === subsections become top-level doc.Children (not nested under a SectionNode). // ProcessInclude uses a different loop that does NOT promote == to doc.Title — it creates // a SectionNode — so included files behave correctly during chunking. - const string source = """ + const string source = + """ == The search API Some intro text. @@ -254,7 +261,8 @@ Common options content. [Fact] public void ChunkLevel2_keeps_level3_within_level2_page() { - const string source = """ + const string source = + """ = Book Title [[search-your-data]] @@ -299,12 +307,14 @@ public void IncludeChain_EachIncludedFile_BecomesASeparatePage() // - search-api.adoc includes sort-results.adoc (=== level, still chunk boundary) var files = new Dictionary { - ["/base/index.adoc"] = """ + ["/base/index.adoc"] = + """ = Elasticsearch Guide include::search-your-data.adoc[] """, - ["/base/search-your-data.adoc"] = """ + ["/base/search-your-data.adoc"] = + """ [[search-with-elasticsearch]] = Search your data @@ -317,7 +327,8 @@ Inline section content. include::search-api.adoc[] """, - ["/base/search-api.adoc"] = """ + ["/base/search-api.adoc"] = + """ [[search-your-data-api]] == The search API @@ -330,7 +341,8 @@ Inline API section. include::sort-results.adoc[] """, - ["/base/sort-results.adoc"] = """ + ["/base/sort-results.adoc"] = + """ [[sort-results]] === Sort search results @@ -338,10 +350,7 @@ Sort content. """, }; - var parser = new AsciidocParser(new AsciidocParserOptions - { - FileReader = path => files.TryGetValue(path, out var c) ? c : null - }); + var parser = new AsciidocParser(new AsciidocParserOptions { FileReader = path => files.TryGetValue(path, out var c) ? c : null }); var doc = parser.Parse(files["/base/index.adoc"], "/base"); var emitter = new MarkdownEmitter(new MarkdownEmitterOptions { BookPrefix = "test", Version = "1.0" }); var pages = PageChunker.Chunk(doc, chunkLevel: 1, emitter); @@ -358,14 +367,14 @@ Sort content. slugs.Should().Contain("search-with-elasticsearch"); var searchYourData = allPages.First(p => p.Slug == "search-with-elasticsearch"); searchYourData.MarkdownContent.Should().Contain("Intro paragraph"); - searchYourData.MarkdownContent.Should().Contain("Run a search"); // inline section stays - searchYourData.MarkdownContent.Should().NotContain("The search API"); // NOT merged into this page + searchYourData.MarkdownContent.Should().Contain("Run a search"); // inline section stays + searchYourData.MarkdownContent.Should().NotContain("The search API"); // NOT merged into this page // == The search API → child page of search-with-elasticsearch (nested include) slugs.Should().Contain("search-your-data-api"); var theSearchApi = allPages.First(p => p.Slug == "search-your-data-api"); theSearchApi.MarkdownContent.Should().Contain("API intro"); - theSearchApi.MarkdownContent.Should().Contain("API Run a search"); // inline stays + theSearchApi.MarkdownContent.Should().Contain("API Run a search"); // inline stays theSearchApi.MarkdownContent.Should().NotContain("Sort search results"); // NOT merged // === Sort search results → child page of search-your-data-api (nested include) diff --git a/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterRoundTripTests.cs b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterRoundTripTests.cs index c93ea2027e..40e122315d 100644 --- a/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterRoundTripTests.cs +++ b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterRoundTripTests.cs @@ -17,10 +17,7 @@ public class ApplicableToJsonConverterRoundTripTests [Fact] public void RoundTripStackSimple() { - var original = new ApplicableTo - { - Stack = AppliesCollection.GenerallyAvailable - }; + var original = new ApplicableTo { Stack = AppliesCollection.GenerallyAvailable }; var json = JsonSerializer.Serialize(original, _options); var deserialized = JsonSerializer.Deserialize(json, _options); @@ -35,11 +32,11 @@ public void RoundTripStackWithVersion() { var original = new ApplicableTo { - Stack = new AppliesCollection( - [ - new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"8.0.0" }, - new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"7.17.0" } - ]) + Stack = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"8.0.0" }, + new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"7.17.0" } + ]) }; var json = JsonSerializer.Serialize(original, _options); @@ -58,7 +55,10 @@ public void RoundTripDeploymentAllProperties() Deployment = new DeploymentApplicability { Self = AppliesCollection.GenerallyAvailable, - Ece = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"3.0.0" }]), + Ece = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"3.0.0" } + ]), Eck = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"2.0.0" }]), Ess = AppliesCollection.GenerallyAvailable } @@ -83,7 +83,10 @@ public void RoundTripServerlessAllProperties() Serverless = new ServerlessProjectApplicability { Elasticsearch = AppliesCollection.GenerallyAvailable, - Observability = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = AllVersionsSpec.Instance }]), + Observability = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = AllVersionsSpec.Instance } + ]), Security = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"1.0.0" }]) } }; @@ -101,10 +104,7 @@ public void RoundTripServerlessAllProperties() [Fact] public void RoundTripProductSimple() { - var original = new ApplicableTo - { - Product = AppliesCollection.GenerallyAvailable - }; + var original = new ApplicableTo { Product = AppliesCollection.GenerallyAvailable }; var json = JsonSerializer.Serialize(original, _options); var deserialized = JsonSerializer.Deserialize(json, _options); @@ -119,10 +119,7 @@ public void RoundTripProductApplicabilitySingleProduct() { var original = new ApplicableTo { - ProductApplicability = new ProductApplicability - { - Ecctl = AppliesCollection.GenerallyAvailable - } + ProductApplicability = new ProductApplicability { Ecctl = AppliesCollection.GenerallyAvailable } }; var json = JsonSerializer.Serialize(original, _options); @@ -141,9 +138,14 @@ public void RoundTripProductApplicabilityMultipleProducts() ProductApplicability = new ProductApplicability { Ecctl = AppliesCollection.GenerallyAvailable, - Curator = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (VersionSpec)"5.0.0" }]), - ApmAgentDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"1.2.0" }]), - EdotDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"0.9.0" }]) + Curator = + new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (VersionSpec)"5.0.0" }]), + ApmAgentDotnet = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"1.2.0" } + ]), + EdotDotnet = + new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"0.9.0" }]) } }; @@ -166,27 +168,68 @@ public void RoundTripAllProductApplicabilityProperties() ProductApplicability = new ProductApplicability { Ecctl = AppliesCollection.GenerallyAvailable, - Curator = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (VersionSpec)"5.0.0" }]), - ApmAgentAndroid = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"1.0.0" }]), - ApmAgentDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"1.2.0" }]), - ApmAgentGo = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"2.0.0" }]), - ApmAgentIos = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (VersionSpec)"0.5.0" }]), - ApmAgentJava = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"1.30.0" }]), - ApmAgentNode = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"3.0.0" }]), - ApmAgentPhp = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"1.8.0" }]), - ApmAgentPython = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"6.0.0" }]), - ApmAgentRuby = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"4.0.0" }]), - ApmAgentRumJs = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"5.0.0" }]), + Curator = + new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (VersionSpec)"5.0.0" }]), + ApmAgentAndroid = + new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"1.0.0" }]), + ApmAgentDotnet = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"1.2.0" } + ]), + ApmAgentGo = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"2.0.0" } + ]), + ApmAgentIos = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (VersionSpec)"0.5.0" } + ]), + ApmAgentJava = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"1.30.0" } + ]), + ApmAgentNode = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"3.0.0" } + ]), + ApmAgentPhp = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"1.8.0" } + ]), + ApmAgentPython = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"6.0.0" } + ]), + ApmAgentRuby = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"4.0.0" } + ]), + ApmAgentRumJs = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"5.0.0" } + ]), EdotIos = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"0.9.0" }]), - EdotAndroid = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"0.8.0" }]), - EdotDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"0.9.0" }]), + EdotAndroid = + new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"0.8.0" }]), + EdotDotnet = + new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"0.9.0" }]), EdotJava = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"0.7.0" }]), EdotNode = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"0.6.0" }]), EdotPhp = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"0.5.0" }]), - EdotPython = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"0.4.0" }]), - EdotCfAws = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (VersionSpec)"0.3.0" }]), - EdotCfAzure = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (VersionSpec)"0.2.0" }]), - EdotCollector = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"1.0.0" }]) + EdotPython = + new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"0.4.0" }]), + EdotCfAws = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (VersionSpec)"0.3.0" } + ]), + EdotCfAzure = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (VersionSpec)"0.2.0" } + ]), + EdotCollector = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"1.0.0" } + ]) } }; @@ -224,29 +267,38 @@ public void RoundTripComplexAllFieldsPopulated() { var original = new ApplicableTo { - Stack = new AppliesCollection( - [ - new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"8.0.0" }, - new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"7.17.0" } - ]), + Stack = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"8.0.0" }, + new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"7.17.0" } + ]), Deployment = new DeploymentApplicability { Self = AppliesCollection.GenerallyAvailable, - Ece = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"3.0.0" }]), + Ece = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"3.0.0" } + ]), Eck = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"2.0.0" }]), Ess = AppliesCollection.GenerallyAvailable }, Serverless = new ServerlessProjectApplicability { Elasticsearch = AppliesCollection.GenerallyAvailable, - Observability = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = AllVersionsSpec.Instance }]), + Observability = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = AllVersionsSpec.Instance } + ]), Security = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"1.0.0" }]) }, Product = AppliesCollection.GenerallyAvailable, ProductApplicability = new ProductApplicability { Ecctl = AppliesCollection.GenerallyAvailable, - ApmAgentDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"1.2.0" }]) + ApmAgentDotnet = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"1.2.0" } + ]) } }; @@ -277,7 +329,10 @@ public void RoundTripDeploymentEssRoundTripsCorrectly() { Deployment = new DeploymentApplicability { - Ess = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"9.0.0" }]) + Ess = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"9.0.0" } + ]) } }; @@ -292,7 +347,8 @@ public void RoundTripDeploymentEssRoundTripsCorrectly() [Fact] public void BothEssAndEchSubTypes_EchWins() { - var json = """ + var json = + """ [ { "type": "deployment", "sub_type": "ess", "lifecycle": "ga", "version": "9.0.0" }, { "type": "deployment", "sub_type": "ech", "lifecycle": "beta", "version": "9.1.0" } @@ -310,7 +366,8 @@ public void BothEssAndEchSubTypes_EchWins() [Fact] public void DeserializeExperimentalLifecycle() { - var json = """ + var json = + """ [ { "type": "stack", "sub_type": "stack", "lifecycle": "experimental", "version": "9.1.0" } ] @@ -327,14 +384,9 @@ public void DeserializeExperimentalLifecycle() public void RoundTripAllLifecycles() { var lifecycles = Enum.GetValues(); - var applicabilities = lifecycles.Select(lc => - new Applicability { Lifecycle = lc, Version = (VersionSpec)"1.0.0" } - ).ToArray(); + var applicabilities = lifecycles.Select(lc => new Applicability { Lifecycle = lc, Version = (VersionSpec)"1.0.0" }).ToArray(); - var original = new ApplicableTo - { - Stack = new AppliesCollection(applicabilities) - }; + var original = new ApplicableTo { Stack = new AppliesCollection(applicabilities) }; var json = JsonSerializer.Serialize(original, _options); var deserialized = JsonSerializer.Deserialize(json, _options); @@ -349,13 +401,13 @@ public void RoundTripMultipleApplicabilitiesInCollection() { var original = new ApplicableTo { - Stack = new AppliesCollection( - [ - new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"8.0.0" }, - new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"7.17.0" }, - new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (VersionSpec)"7.16.0" }, - new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (VersionSpec)"6.0.0" } - ]) + Stack = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"8.0.0" }, + new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"7.17.0" }, + new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (VersionSpec)"7.16.0" }, + new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (VersionSpec)"6.0.0" } + ]) }; var json = JsonSerializer.Serialize(original, _options); @@ -399,7 +451,10 @@ public void RoundTripAllVersionsSerializesAsSemanticVersion() { var original = new ApplicableTo { - Stack = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = AllVersionsSpec.Instance }]) + Stack = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = AllVersionsSpec.Instance } + ]) }; var json = JsonSerializer.Serialize(original, _options); diff --git a/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs index cf9cd57f58..0d47c7ff11 100644 --- a/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs +++ b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs @@ -13,19 +13,12 @@ namespace Elastic.Markdown.Tests.AppliesTo; public class ApplicableToJsonConverterSerializationTests { - private readonly JsonSerializerOptions _options = new() - { - WriteIndented = true, - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }; + private readonly JsonSerializerOptions _options = new() { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; [Fact] public void SerializeStackProducesCorrectJson() { - var applicableTo = new ApplicableTo - { - Stack = AppliesCollection.GenerallyAvailable - }; + var applicableTo = new ApplicableTo { Stack = AppliesCollection.GenerallyAvailable }; var json = JsonSerializer.Serialize(applicableTo, _options); @@ -40,7 +33,8 @@ public void SerializeStackProducesCorrectJson() "version": "all" } ] - """); + """ + ); } [Fact] @@ -48,13 +42,7 @@ public void SerializeStackWithVersionProducesCorrectJson() { var applicableTo = new ApplicableTo { - Stack = new AppliesCollection([ - new Applicability - { - Lifecycle = ProductLifecycle.Beta, - Version = (VersionSpec)"8.0.0" - } - ]) + Stack = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"8.0.0" }]) }; var json = JsonSerializer.Serialize(applicableTo, _options); @@ -70,7 +58,8 @@ public void SerializeStackWithVersionProducesCorrectJson() "version": "8.0+" } ] - """); + """ + ); } [Fact] @@ -78,19 +67,11 @@ public void SerializeMultipleApplicabilitiesProducesCorrectJson() { var applicableTo = new ApplicableTo { - Stack = new AppliesCollection( - [ - new Applicability - { - Lifecycle = ProductLifecycle.GenerallyAvailable, - Version = (VersionSpec)"8.0.0" - }, - new Applicability - { - Lifecycle = ProductLifecycle.Beta, - Version = (VersionSpec)"7.17.0" - } - ]) + Stack = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"8.0.0" }, + new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"7.17.0" } + ]) }; var json = JsonSerializer.Serialize(applicableTo, _options); @@ -112,7 +93,8 @@ public void SerializeMultipleApplicabilitiesProducesCorrectJson() "version": "7.17+" } ] - """); + """ + ); } [Fact] @@ -122,13 +104,10 @@ public void SerializeDeploymentProducesCorrectJson() { Deployment = new DeploymentApplicability { - Ece = new AppliesCollection([ - new Applicability - { - Lifecycle = ProductLifecycle.GenerallyAvailable, - Version = (VersionSpec)"3.0.0" - } - ]), + Ece = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"3.0.0" } + ]), Ess = AppliesCollection.GenerallyAvailable } }; @@ -152,7 +131,8 @@ public void SerializeDeploymentProducesCorrectJson() "version": "all" } ] - """); + """ + ); } [Fact] @@ -162,13 +142,8 @@ public void SerializeServerlessProducesCorrectJson() { Serverless = new ServerlessProjectApplicability { - Elasticsearch = new AppliesCollection([ - new Applicability - { - Lifecycle = ProductLifecycle.Beta, - Version = (VersionSpec)"1.0.0" - } - ]), + Elasticsearch = + new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"1.0.0" }]), Security = AppliesCollection.GenerallyAvailable } }; @@ -192,7 +167,8 @@ public void SerializeServerlessProducesCorrectJson() "version": "all" } ] - """); + """ + ); } [Fact] @@ -200,13 +176,8 @@ public void SerializeProductProducesCorrectJson() { var applicableTo = new ApplicableTo { - Product = new AppliesCollection([ - new Applicability - { - Lifecycle = ProductLifecycle.TechnicalPreview, - Version = (VersionSpec)"0.5.0" - } - ]) + Product = + new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (VersionSpec)"0.5.0" }]) }; var json = JsonSerializer.Serialize(applicableTo, _options); @@ -222,7 +193,8 @@ public void SerializeProductProducesCorrectJson() "version": "0.5+" } ] - """); + """ + ); } [Fact] @@ -232,13 +204,8 @@ public void SerializeProductApplicabilityProducesCorrectJson() { ProductApplicability = new ProductApplicability { - Ecctl = new AppliesCollection([ - new Applicability - { - Lifecycle = ProductLifecycle.Deprecated, - Version = (VersionSpec)"5.0.0" - } - ]), + Ecctl = + new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (VersionSpec)"5.0.0" }]), ApmAgentDotnet = AppliesCollection.GenerallyAvailable } }; @@ -262,7 +229,8 @@ public void SerializeProductApplicabilityProducesCorrectJson() "version": "all" } ] - """); + """ + ); } [Fact] @@ -270,39 +238,15 @@ public void SerializeAllLifecyclesProducesCorrectJson() { var applicableTo = new ApplicableTo { - Stack = new AppliesCollection( - [ - new Applicability - { - Lifecycle = ProductLifecycle.TechnicalPreview, - Version = (VersionSpec)"1.0.0" - }, - new Applicability - { - Lifecycle = ProductLifecycle.Experimental, - Version = (VersionSpec)"1.0.0" - }, - new Applicability - { - Lifecycle = ProductLifecycle.Beta, - Version = (VersionSpec)"1.0.0" - }, - new Applicability - { - Lifecycle = ProductLifecycle.GenerallyAvailable, - Version = (VersionSpec)"1.0.0" - }, - new Applicability - { - Lifecycle = ProductLifecycle.Deprecated, - Version = (VersionSpec)"1.0.0" - }, - new Applicability - { - Lifecycle = ProductLifecycle.Removed, - Version = (VersionSpec)"1.0.0" - } - ]) + Stack = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (VersionSpec)"1.0.0" }, + new Applicability { Lifecycle = ProductLifecycle.Experimental, Version = (VersionSpec)"1.0.0" }, + new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"1.0.0" }, + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"1.0.0" }, + new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (VersionSpec)"1.0.0" }, + new Applicability { Lifecycle = ProductLifecycle.Removed, Version = (VersionSpec)"1.0.0" } + ]) }; var json = JsonSerializer.Serialize(applicableTo, _options); @@ -320,17 +264,11 @@ public void SerializeComplexProducesCorrectJson() { var applicableTo = new ApplicableTo { - Stack = new AppliesCollection([ - new Applicability - { - Lifecycle = ProductLifecycle.GenerallyAvailable, - Version = (VersionSpec)"8.0.0" - } - ]), - Deployment = new DeploymentApplicability - { - Ece = AppliesCollection.GenerallyAvailable - }, + Stack = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"8.0.0" } + ]), + Deployment = new DeploymentApplicability { Ece = AppliesCollection.GenerallyAvailable }, Product = AppliesCollection.GenerallyAvailable }; @@ -369,13 +307,7 @@ public void SerializeValidatesJsonStructure() Stack = AppliesCollection.GenerallyAvailable, Deployment = new DeploymentApplicability { - Ece = new AppliesCollection([ - new Applicability - { - Lifecycle = ProductLifecycle.Beta, - Version = (VersionSpec)"3.0.0" - } - ]) + Ece = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"3.0.0" }]) } }; diff --git a/tests/Elastic.Markdown.Tests/AppliesTo/ProductApplicabilityToStringTests.cs b/tests/Elastic.Markdown.Tests/AppliesTo/ProductApplicabilityToStringTests.cs index 34dbf86941..6059773338 100644 --- a/tests/Elastic.Markdown.Tests/AppliesTo/ProductApplicabilityToStringTests.cs +++ b/tests/Elastic.Markdown.Tests/AppliesTo/ProductApplicabilityToStringTests.cs @@ -19,9 +19,7 @@ public void ProductApplicabilityToStringIncludesAllProperties() // Create a ProductApplicability with all properties set var productApplicability = new ProductApplicability(); var productType = typeof(ProductApplicability); - var properties = productType.GetProperties() - .Where(p => p.GetCustomAttribute() != null) - .ToList(); + var properties = productType.GetProperties().Where(p => p.GetCustomAttribute() != null).ToList(); // Set all properties to a test value var testValue = AppliesCollection.GenerallyAvailable; @@ -37,8 +35,7 @@ public void ProductApplicabilityToStringIncludesAllProperties() foreach (var property in properties) { var jsonName = property.GetCustomAttribute()!.Name; - result.Should().Contain($"{jsonName}=", - $"ToString should include the property {property.Name} with alias '{jsonName}'"); + result.Should().Contain($"{jsonName}=", $"ToString should include the property {property.Name} with alias '{jsonName}'"); } // Verify we have the expected number of properties @@ -51,7 +48,10 @@ public void ProductApplicabilityToStringWithSomePropertiesOnlyIncludesSetPropert var productApplicability = new ProductApplicability { ApmAgentDotnet = AppliesCollection.GenerallyAvailable, - Ecctl = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = VersionSpec.TryParse("1.0.0", out var v) ? v : null }]) + Ecctl = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.Beta, Version = VersionSpec.TryParse("1.0.0", out var v) ? v : null } + ]) }; var result = productApplicability.ToString(); @@ -105,9 +105,7 @@ public void ProductApplicabilityToStringPropertyOrderMatchesReflectionOrder() } // Verify that the properties appear in the correct order - positions["ecctl"].Should().BeLessThan(positions["curator"], - "ecctl should appear before curator"); - positions["curator"].Should().BeLessThan(positions["apm-agent-android"], - "curator should appear before apm-agent-android"); + positions["ecctl"].Should().BeLessThan(positions["curator"], "ecctl should appear before curator"); + positions["curator"].Should().BeLessThan(positions["apm-agent-android"], "curator should appear before apm-agent-android"); } } diff --git a/tests/Elastic.Markdown.Tests/AppliesTo/ProductLifecycleInfoTests.cs b/tests/Elastic.Markdown.Tests/AppliesTo/ProductLifecycleInfoTests.cs index 791cfe14d2..d10277e270 100644 --- a/tests/Elastic.Markdown.Tests/AppliesTo/ProductLifecycleInfoTests.cs +++ b/tests/Elastic.Markdown.Tests/AppliesTo/ProductLifecycleInfoTests.cs @@ -18,10 +18,8 @@ public void Experimental_HasExpectedMetadata() } [Fact] - public void Experimental_IsLessMatureThanPreview() - { + public void Experimental_IsLessMatureThanPreview() => ProductLifecycleInfo.GetOrder(ProductLifecycle.Experimental) .Should() .BeGreaterThan(ProductLifecycleInfo.GetOrder(ProductLifecycle.TechnicalPreview)); - } } diff --git a/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs b/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs index 9ba1bfd67a..6955547264 100644 --- a/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs @@ -22,7 +22,8 @@ public class AssemblerHtmxMarkdownLinkTests(ITestOutputHelper output) : LinkTest protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, - IConfigurationContext configurationContext) => + IConfigurationContext configurationContext + ) => new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs/platform/elasticsearch", @@ -37,8 +38,7 @@ public void CrossLink_HasNoSelectOobButKeepsPreload() } [Fact] - public void CrossLink_NoTargetBlank() => - Html.Should().NotContain("target=\"_blank\""); + public void CrossLink_NoTargetBlank() => Html.Should().NotContain("target=\"_blank\""); [Fact] public void EmitsCrossLink() @@ -57,7 +57,8 @@ public class AssemblerHtmxInternalLinkTests(ITestOutputHelper output) : LinkTest protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, - IConfigurationContext configurationContext) => + IConfigurationContext configurationContext + ) => new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs/platform/elasticsearch", @@ -65,8 +66,7 @@ protected override BuildContext CreateBuildContext( }; [Fact] - public void InternalLink_HasNoPerLinkHtmxAttributes() => - Html.Should().NotContain("hx-select-oob"); + public void InternalLink_HasNoPerLinkHtmxAttributes() => Html.Should().NotContain("hx-select-oob"); [Fact] public void EmitsNoCrossLink() => Collector.CrossLinks.Should().HaveCount(0); @@ -76,8 +76,9 @@ public void InternalLink_HasNoPerLinkHtmxAttributes() => } /// Absolute path links in assembler carry no per-link htmx attributes. -public class AssemblerHtmxAbsolutePathLinkTests(ITestOutputHelper output) : LinkTestBase(output, -""" +public class AssemblerHtmxAbsolutePathLinkTests(ITestOutputHelper output) : LinkTestBase( + output, + """ [Elasticsearch](/_static/img/observability.png) """ ) @@ -85,7 +86,8 @@ public class AssemblerHtmxAbsolutePathLinkTests(ITestOutputHelper output) : Link protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, - IConfigurationContext configurationContext) => + IConfigurationContext configurationContext + ) => new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs", @@ -104,18 +106,17 @@ public void AbsolutePathLink_HasNoSelectOobButKeepsPreload() } /// Reference-style internal links in assembler carry no per-link htmx attributes. -public class AssemblerHtmxReferenceLinkTests(ITestOutputHelper output) : LinkTestBase(output, -""" +public class AssemblerHtmxReferenceLinkTests(ITestOutputHelper output) : LinkTestBase(output, """ [test][test] [test]: testing/req.md -""" -) +""") { protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, - IConfigurationContext configurationContext) => + IConfigurationContext configurationContext + ) => new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs/platform/elasticsearch", @@ -123,8 +124,7 @@ protected override BuildContext CreateBuildContext( }; [Fact] - public void ReferenceLink_HasNoPerLinkHtmxAttributes() => - Html.Should().NotContain("hx-select-oob"); + public void ReferenceLink_HasNoPerLinkHtmxAttributes() => Html.Should().NotContain("hx-select-oob"); [Fact] public void EmitsNoCrossLink() => Collector.CrossLinks.Should().HaveCount(0); @@ -134,17 +134,16 @@ public void ReferenceLink_HasNoPerLinkHtmxAttributes() => } /// Empty-text cross-links in assembler carry no per-link htmx attributes (and emit error). -public class AssemblerHtmxEmptyTextCrossLinkTests(ITestOutputHelper output) : LinkTestBase(output, -""" +public class AssemblerHtmxEmptyTextCrossLinkTests(ITestOutputHelper output) : LinkTestBase(output, """ Go to [](kibana://index.md) -""" -) +""") { protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, - IConfigurationContext configurationContext) => + IConfigurationContext configurationContext + ) => new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs/platform/elasticsearch", @@ -152,18 +151,14 @@ protected override BuildContext CreateBuildContext( }; [Fact] - public void EmptyTextCrossLink_HasNoPerLinkHtmxAttributes() => - Html.Should().NotContain("hx-select-oob"); + public void EmptyTextCrossLink_HasNoPerLinkHtmxAttributes() => Html.Should().NotContain("hx-select-oob"); [Fact] - public void EmptyTextCrossLink_NoTargetBlank() => - Html.Should().NotContain("target=\"_blank\""); + public void EmptyTextCrossLink_NoTargetBlank() => Html.Should().NotContain("target=\"_blank\""); [Fact] public void HasError() => - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && - d.Message.Contains("empty link text")); + Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("empty link text")); [Fact] public void EmitsCrossLink() @@ -174,16 +169,15 @@ public void EmitsCrossLink() } /// Insert-page-title links (empty text, internal target) carry no per-link htmx attributes. -public class AssemblerHtmxInsertPageTitleTests(ITestOutputHelper output) : LinkTestBase(output, -""" +public class AssemblerHtmxInsertPageTitleTests(ITestOutputHelper output) : LinkTestBase(output, """ [](testing/req.md) -""" -) +""") { protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, - IConfigurationContext configurationContext) => + IConfigurationContext configurationContext + ) => new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs/platform/elasticsearch", @@ -191,8 +185,7 @@ protected override BuildContext CreateBuildContext( }; [Fact] - public void InsertPageTitle_HasNoPerLinkHtmxAttributes() => - Html.Should().NotContain("hx-select-oob"); + public void InsertPageTitle_HasNoPerLinkHtmxAttributes() => Html.Should().NotContain("hx-select-oob"); [Fact] public void EmitsNoCrossLink() => Collector.CrossLinks.Should().HaveCount(0); @@ -202,8 +195,9 @@ public void InsertPageTitle_HasNoPerLinkHtmxAttributes() => } /// HTTP links in assembler get target="_blank" and no htmx attributes. -public class AssemblerHtmxExternalLinkTests(ITestOutputHelper output) : LinkTestBase(output, -""" +public class AssemblerHtmxExternalLinkTests(ITestOutputHelper output) : LinkTestBase( + output, + """ [link to app]({{some-url-with-a-version}}) """ ) @@ -211,7 +205,8 @@ [link to app]({{some-url-with-a-version}}) protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, - IConfigurationContext configurationContext) => + IConfigurationContext configurationContext + ) => new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs/platform/elasticsearch", diff --git a/tests/Elastic.Markdown.Tests/BrandingCopyTests.cs b/tests/Elastic.Markdown.Tests/BrandingCopyTests.cs index 8c640eff1d..1e0b104a32 100644 --- a/tests/Elastic.Markdown.Tests/BrandingCopyTests.cs +++ b/tests/Elastic.Markdown.Tests/BrandingCopyTests.cs @@ -18,23 +18,25 @@ public async Task CopyBrandingResources_SeparateFileSystems_DoesNotThrow() { var logger = new TestLoggerFactory(output); - var fs = new MockFileSystem(new Dictionary - { - { "docs/docset.yml", - //language=yaml - new MockFileData(""" + var fs = new MockFileSystem( + new Dictionary + { + { + "docs/docset.yml", + //language=yaml + new MockFileData(""" project: test toc: - file: index.md branding: icon: assets/logo.svg -""") }, - { "docs/index.md", new MockFileData("# Hello") }, - { "docs/assets/logo.svg", new MockFileData("") } - }, new MockFileSystemOptions - { - CurrentDirectory = Paths.WorkingDirectoryRoot.FullName - }); +""") + }, + { "docs/index.md", new MockFileData("# Hello") }, + { "docs/assets/logo.svg", new MockFileData("") } + }, + new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName } + ); await using var collector = new DiagnosticsCollector([]).StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fs); diff --git a/tests/Elastic.Markdown.Tests/BuildContextConfigurationFileTests.cs b/tests/Elastic.Markdown.Tests/BuildContextConfigurationFileTests.cs index fa708156ce..c218a13ca7 100644 --- a/tests/Elastic.Markdown.Tests/BuildContextConfigurationFileTests.cs +++ b/tests/Elastic.Markdown.Tests/BuildContextConfigurationFileTests.cs @@ -43,7 +43,8 @@ public void ExplicitConfigurationFile_OverridesDefaultDiscovery() Inner = fs, ConfigurationFile = internalDocsetPath, Output = Path.Join(root, "codex-configuration-file-test-out") - }); + } + ); var context = new BuildContext(collector, docFs, configurationContext); @@ -70,11 +71,8 @@ public void NoExplicitConfigurationFile_FallsBackToDefaultDiscovery() var configurationContext = TestHelpers.CreateConfigurationContext(fs); var docFs = DocumentationFileSystem.Resolve( fs.DirectoryInfo.New(repoPath), - new DocumentationScopeOptions - { - Inner = fs, - Output = Path.Join(root, "codex-configuration-file-fallback-test-out") - }); + new DocumentationScopeOptions { Inner = fs, Output = Path.Join(root, "codex-configuration-file-fallback-test-out") } + ); var context = new BuildContext(collector, docFs, configurationContext); diff --git a/tests/Elastic.Markdown.Tests/BuildContextDocumentationCheckoutDirectoryTests.cs b/tests/Elastic.Markdown.Tests/BuildContextDocumentationCheckoutDirectoryTests.cs index a69f2a8ba1..2f02369a4c 100644 --- a/tests/Elastic.Markdown.Tests/BuildContextDocumentationCheckoutDirectoryTests.cs +++ b/tests/Elastic.Markdown.Tests/BuildContextDocumentationCheckoutDirectoryTests.cs @@ -41,11 +41,8 @@ public void SourceAsRepositoryRoot_SetsDocumentationCheckoutDirectory() var configurationContext = TestHelpers.CreateConfigurationContext(fs); var docFs = DocumentationFileSystem.Resolve( fs.DirectoryInfo.New(repoPath), - new DocumentationScopeOptions - { - Inner = fs, - Output = Path.Join(root, "codex-checkout-dir-test-out") - }); + new DocumentationScopeOptions { Inner = fs, Output = Path.Join(root, "codex-checkout-dir-test-out") } + ); var context = new BuildContext(collector, docFs, configurationContext); Assert.NotNull(context.DocumentationCheckoutDirectory); @@ -67,11 +64,8 @@ public void SourceAsDocsSubtree_ResolvesCheckoutFromParent() var configurationContext = TestHelpers.CreateConfigurationContext(fs); var docFs = DocumentationFileSystem.Resolve( fs.DirectoryInfo.New(docsPath), - new DocumentationScopeOptions - { - Inner = fs, - Output = Path.Join(root, "codex-docs-only-test-out") - }); + new DocumentationScopeOptions { Inner = fs, Output = Path.Join(root, "codex-docs-only-test-out") } + ); var context = new BuildContext(collector, docFs, configurationContext); // --path repo/docs/ now resolves the same checkout as --path repo/: @@ -93,11 +87,7 @@ public void PathAndDocsSubfolder_ResolveIdenticalCheckout() var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fs); - var opts = new DocumentationScopeOptions - { - Inner = fs, - Output = Path.Combine(root, "codex-equiv-test-out") - }; + var opts = new DocumentationScopeOptions { Inner = fs, Output = Path.Combine(root, "codex-equiv-test-out") }; var fsFromRepoRoot = DocumentationFileSystem.Resolve(fs.DirectoryInfo.New(repoPath), opts); var fsFromDocsFolder = DocumentationFileSystem.Resolve(fs.DirectoryInfo.New(docsPath), opts); @@ -106,11 +96,19 @@ public void PathAndDocsSubfolder_ResolveIdenticalCheckout() contextFromRepoRoot.DocumentationCheckoutDirectory.Should().NotBeNull(); contextFromDocsFolder.DocumentationCheckoutDirectory.Should().NotBeNull(); - contextFromRepoRoot.DocumentationCheckoutDirectory.FullName - .Should().Be(contextFromDocsFolder.DocumentationCheckoutDirectory.FullName, - "--path repo/ and --path repo/docs/ must resolve to the same CheckoutDirectory"); - contextFromRepoRoot.DocumentationSourceDirectory.FullName - .Should().Be(contextFromDocsFolder.DocumentationSourceDirectory.FullName, - "--path repo/ and --path repo/docs/ must resolve to the same SourceDirectory"); + contextFromRepoRoot.DocumentationCheckoutDirectory + .FullName + .Should() + .Be( + contextFromDocsFolder.DocumentationCheckoutDirectory.FullName, + "--path repo/ and --path repo/docs/ must resolve to the same CheckoutDirectory" + ); + contextFromRepoRoot.DocumentationSourceDirectory + .FullName + .Should() + .Be( + contextFromDocsFolder.DocumentationSourceDirectory.FullName, + "--path repo/ and --path repo/docs/ must resolve to the same SourceDirectory" + ); } } diff --git a/tests/Elastic.Markdown.Tests/CliReference/CliSupplementalDocTests.cs b/tests/Elastic.Markdown.Tests/CliReference/CliSupplementalDocTests.cs index 5de73f634a..d923e1aa5e 100644 --- a/tests/Elastic.Markdown.Tests/CliReference/CliSupplementalDocTests.cs +++ b/tests/Elastic.Markdown.Tests/CliReference/CliSupplementalDocTests.cs @@ -14,7 +14,8 @@ public class CliSupplementalDocTests public void RootPage_PreservesFrontMatterAsMetadata() { var schema = CreateSchema(); - const string raw = """ + const string raw = + """ --- description: Use the Elastic CLI from the command line. applies_to: @@ -33,7 +34,9 @@ public void RootPage_PreservesFrontMatterAsMetadata() --- # elastic - """.ReplaceLineEndings("\n"); + """.ReplaceLineEndings( + "\n" + ); markdown.Should().StartWith(expectedStart); markdown.Should().NotContain("description: Use the Elastic CLI from the command line.\n\n"); @@ -43,7 +46,8 @@ public void RootPage_PreservesFrontMatterAsMetadata() public void RootPage_StripsFrontMatterBeforeParsingDescription() { var schema = CreateSchema(); - const string raw = """ + const string raw = + """ --- description: Metadata description. --- @@ -58,13 +62,14 @@ public void RootPage_StripsFrontMatterBeforeParsingDescription() markdown.Should().NotContain("\nMetadata description.\n"); } - private static CliSchema CreateSchema() => new( - SchemaVersion: 1, - Name: "elastic", - Description: "Schema description.", - GlobalOptions: [], - RootDefault: null, - Commands: [], - Namespaces: [] - ); + private static CliSchema CreateSchema() => + new( + SchemaVersion: 1, + Name: "elastic", + Description: "Schema description.", + GlobalOptions: [], + RootDefault: null, + Commands: [], + Namespaces: [] + ); } diff --git a/tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs b/tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs index ebca2e7bf5..6745ae8cc8 100644 --- a/tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs +++ b/tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs @@ -15,9 +15,9 @@ public abstract class CodeBlockCallOutTests( string language, [LanguageInjection("csharp")] string code, [LanguageInjection("markdown")] string? markdown = null -) - : BlockTest(output, -$$""" +) : BlockTest( + output, + $$""" ```{{language}} {{code}} ``` @@ -30,33 +30,34 @@ public abstract class CodeBlockCallOutTests( [Fact] public void SetsLanguage() => Block!.Language.Should().Be("csharp"); - } -public class MagicCalOuts(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", -""" +public class MagicCalOuts(ITestOutputHelper output) : CodeBlockCallOutTests( + output, + "csharp", + """ var x = 1; // this is a callout //this is not a callout var y = x - 2; var z = y - 2; // another callout """ - ) +) { [Fact] - public void ParsesMagicCallOuts() => Block!.CallOuts - .Should().NotBeNullOrEmpty() - .And.HaveCount(2) - .And.NotContain(c => c.Text.Contains("not a callout")); + public void ParsesMagicCallOuts() => + Block!.CallOuts.Should().NotBeNullOrEmpty().And.HaveCount(2).And.NotContain(c => c.Text.Contains("not a callout")); [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class MagicCallOutWithFormatting(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", -""" +public class MagicCallOutWithFormatting(ITestOutputHelper output) : CodeBlockCallOutTests( + output, + "csharp", + """ var x = 1; // this uses `formatting` and a [link](testing/req.md) """ - ) +) { protected override void AddToFileSystem(MockFileSystem fileSystem) => fileSystem.AddFile("docs/testing/req.md", new MockFileData("# Requirements")); @@ -75,70 +76,75 @@ public void RendersFormattedInlineMarkdown() => public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class ClassicCallOutsRequiresContent(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", -""" +public class ClassicCallOutsRequiresContent(ITestOutputHelper output) : CodeBlockCallOutTests( + output, + "csharp", + """ var x = 1; <1> var y = x - 2; var z = y - 2; <2> """ - ) +) { [Fact] - public void ParsesMagicCallOuts() => Block!.CallOuts - .Should().NotBeNullOrEmpty() - .And.HaveCount(2) - .And.OnlyContain(c => c.Text.StartsWith('<')); + public void ParsesMagicCallOuts() => + Block!.CallOuts.Should().NotBeNullOrEmpty().And.HaveCount(2).And.OnlyContain(c => c.Text.StartsWith('<')); [Fact] - public void RequiresContentToFollow() => Collector.Diagnostics.Should().HaveCount(1) - .And.OnlyContain(c => c.Message.StartsWith("Code block with annotations is not followed by any content")); + public void RequiresContentToFollow() => + Collector.Diagnostics + .Should() + .HaveCount(1) + .And + .OnlyContain(c => c.Message.StartsWith("Code block with annotations is not followed by any content")); } -public class ClassicCallOutsNotFollowedByList(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", -""" +public class ClassicCallOutsNotFollowedByList(ITestOutputHelper output) : CodeBlockCallOutTests( + output, + "csharp", + """ var x = 1; <1> var y = x - 2; var z = y - 2; <2> """, -""" + """ ## hello world """ - - ) +) { [Fact] - public void ParsesMagicCallOuts() => Block!.CallOuts - .Should().NotBeNullOrEmpty() - .And.HaveCount(2) - .And.OnlyContain(c => c.Text.StartsWith('<')); + public void ParsesMagicCallOuts() => + Block!.CallOuts.Should().NotBeNullOrEmpty().And.HaveCount(2).And.OnlyContain(c => c.Text.StartsWith('<')); [Fact] - public void RequiresContentToFollow() => Collector.Diagnostics.Should().HaveCount(1) - .And.OnlyContain(c => c.Message.StartsWith("Code block with annotations is not followed by a list")); + public void RequiresContentToFollow() => + Collector.Diagnostics + .Should() + .HaveCount(1) + .And + .OnlyContain(c => c.Message.StartsWith("Code block with annotations is not followed by a list")); } - -public class ClassicCallOutsFollowedByAListWithOneParagraph(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", -""" +public class ClassicCallOutsFollowedByAListWithOneParagraph(ITestOutputHelper output) : CodeBlockCallOutTests( + output, + "csharp", + """ var x = 1; <1> var y = x - 2; var z = y - 2; <2> """, -""" + """ **OUTPUT:** 1. Marking the first callout 2. Marking the second callout """ - - ) +) { [Fact] - public void ParsesMagicCallOuts() => Block!.CallOuts - .Should().NotBeNullOrEmpty() - .And.HaveCount(2) - .And.OnlyContain(c => c.Text.StartsWith('<')); + public void ParsesMagicCallOuts() => + Block!.CallOuts.Should().NotBeNullOrEmpty().And.HaveCount(2).And.OnlyContain(c => c.Text.StartsWith('<')); [Fact] public void RendersExpectedHtml() => @@ -160,18 +166,19 @@ public void RendersExpectedHtml() => """ ); - [Fact] public void AllowsAParagraphInBetween() => Collector.Diagnostics.Should().BeEmpty(); } -public class ClassicCallOutsFollowedByListButWithTwoParagraphs(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", -""" +public class ClassicCallOutsFollowedByListButWithTwoParagraphs(ITestOutputHelper output) : CodeBlockCallOutTests( + output, + "csharp", + """ var x = 1; <1> var y = x - 2; var z = y - 2; <2> """, -""" + """ **OUTPUT:** @@ -180,76 +187,77 @@ BLOCK TWO 1. Marking the first callout 2. Marking the second callout """ - - ) +) { [Fact] - public void ParsesMagicCallOuts() => Block!.CallOuts - .Should().NotBeNullOrEmpty() - .And.HaveCount(2) - .And.OnlyContain(c => c.Text.StartsWith('<')); + public void ParsesMagicCallOuts() => + Block!.CallOuts.Should().NotBeNullOrEmpty().And.HaveCount(2).And.OnlyContain(c => c.Text.StartsWith('<')); [Fact] - public void RequiresContentToFollow() => Collector.Diagnostics.Should().HaveCount(1) - .And.OnlyContain(c => c.Message.StartsWith("More than one content block between code block with annotations and its list")); + public void RequiresContentToFollow() => + Collector.Diagnostics + .Should() + .HaveCount(1) + .And + .OnlyContain(c => c.Message.StartsWith("More than one content block between code block with annotations and its list")); } - - -public class ClassicCallOutsFollowedByListWithWrongCoung(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", -""" +public class ClassicCallOutsFollowedByListWithWrongCoung(ITestOutputHelper output) : CodeBlockCallOutTests( + output, + "csharp", + """ var x = 1; <1> var y = x - 2; var z = y - 2; <2> """, -""" + """ 1. Only marking the first callout """ - - ) +) { [Fact] - public void ParsesMagicCallOuts() => Block!.CallOuts - .Should().NotBeNullOrEmpty() - .And.HaveCount(2) - .And.OnlyContain(c => c.Text.StartsWith('<')); + public void ParsesMagicCallOuts() => + Block!.CallOuts.Should().NotBeNullOrEmpty().And.HaveCount(2).And.OnlyContain(c => c.Text.StartsWith('<')); [Fact] - public void RequiresContentToFollow() => Collector.Diagnostics.Should().HaveCount(1) - .And.OnlyContain(c => c.Message.StartsWith("Code block has 2 callouts but the following list only has 1")); + public void RequiresContentToFollow() => + Collector.Diagnostics + .Should() + .HaveCount(1) + .And + .OnlyContain(c => c.Message.StartsWith("Code block has 2 callouts but the following list only has 1")); } -public class ClassicCallOutsReuseHighlights(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", -""" +public class ClassicCallOutsReuseHighlights(ITestOutputHelper output) : CodeBlockCallOutTests( + output, + "csharp", + """ var x = 1; <1> var y = x - 2; <2> var z = y - 2; <2> """, -""" + """ 1. The first 2. The second appears twice """ - - ) +) { [Fact] - public void SeesTwoUniqueCallouts() => Block!.UniqueCallOuts - .Should().NotBeNullOrEmpty() - .And.HaveCount(2) - .And.OnlyContain(c => c.Text.StartsWith('<')); + public void SeesTwoUniqueCallouts() => + Block!.UniqueCallOuts.Should().NotBeNullOrEmpty().And.HaveCount(2).And.OnlyContain(c => c.Text.StartsWith('<')); [Fact] - public void ParsesAllForLineInformation() => Block!.CallOuts - .Should().NotBeNullOrEmpty() - .And.HaveCount(3) - .And.OnlyContain(c => c.Text.StartsWith('<')); + public void ParsesAllForLineInformation() => + Block!.CallOuts.Should().NotBeNullOrEmpty().And.HaveCount(3).And.OnlyContain(c => c.Text.StartsWith('<')); [Fact] public void RequiresContentToFollow() => Collector.Diagnostics.Should().BeEmpty(); } -public class ClassicCallOutWithTheRightListItems(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", -""" +public class ClassicCallOutWithTheRightListItems(ITestOutputHelper output) : CodeBlockCallOutTests( + output, + "csharp", + """ receivers: <1> # ... otlp: @@ -290,7 +298,7 @@ public class ClassicCallOutWithTheRightListItems(ITestOutputHelper output) : Cod processors: [..., memory_limiter, batch] exporters: [debug, otlp] """, -""" + """ 1. The receivers, like the OTLP receiver, that forward data emitted by APM agents, or the host metrics receiver. 2. We recommend using the Batch processor and the memory limiter processor. For more information, see recommended processors. 3. The debug exporter is helpful for troubleshooting, and supports configurable verbosity levels: basic (default), normal, and detailed. @@ -300,27 +308,23 @@ public class ClassicCallOutWithTheRightListItems(ITestOutputHelper output) : Cod 7. Environment-specific configuration parameters can be conveniently passed in as environment variables documented here (e.g. ELASTIC_APM_SERVER_ENDPOINT and ELASTIC_APM_SECRET_TOKEN). 8. [preview] To send OpenTelemetry logs to {stack} version 8.0+, declare a logs pipeline. """ - - ) +) { [Fact] public void ParsesClassicCallouts() { - Block!.CallOuts - .Should().NotBeNullOrEmpty() - .And.HaveCount(9) - .And.OnlyContain(c => c.Text.StartsWith('<')); - - Block!.UniqueCallOuts - .Should().NotBeNullOrEmpty() - .And.HaveCount(8); + Block!.CallOuts.Should().NotBeNullOrEmpty().And.HaveCount(9).And.OnlyContain(c => c.Text.StartsWith('<')); + + Block!.UniqueCallOuts.Should().NotBeNullOrEmpty().And.HaveCount(8); } [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class MultipleCalloutsInOneLine(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", +public class MultipleCalloutsInOneLine(ITestOutputHelper output) : CodeBlockCallOutTests( + output, + "csharp", """ var x = 1; // <1> var y = x - 2; @@ -333,16 +337,16 @@ 2. Second callout ) { [Fact] - public void ParsesMagicCallOuts() => Block!.CallOuts - .Should().NotBeNullOrEmpty() - .And.HaveCount(3) - .And.OnlyContain(c => c.Text.StartsWith('<')); + public void ParsesMagicCallOuts() => + Block!.CallOuts.Should().NotBeNullOrEmpty().And.HaveCount(3).And.OnlyContain(c => c.Text.StartsWith('<')); [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class CodeBlockWithChevronInsideCode(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", +public class CodeBlockWithChevronInsideCode(ITestOutputHelper output) : CodeBlockCallOutTests( + output, + "csharp", """ app.UseFilter(); <1> app.UseFilter(); <2> @@ -358,22 +362,22 @@ 2. Second callout ) { [Fact] - public void ParsesMagicCallOuts() => Block!.CallOuts - .Should().NotBeNullOrEmpty() - .And.HaveCount(5) - .And.OnlyContain(c => c.Text.StartsWith('<')); + public void ParsesMagicCallOuts() => + Block!.CallOuts.Should().NotBeNullOrEmpty().And.HaveCount(5).And.OnlyContain(c => c.Text.StartsWith('<')); [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class CodeBlockWithCommentBlocksThenList(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", -""" +public class CodeBlockWithCommentBlocksThenList(ITestOutputHelper output) : CodeBlockCallOutTests( + output, + "csharp", + """ var x = 1; <1> var y = x - 2; var z = y - 2; <2> """, -""" + """ % TEST[s/"basque_keywords",//] % TEST[s/\n$/\nstartyaml\n - compare_analyzers: {index: basque_example, first: basque, second: rebuilt_basque}\nendyaml\n/] @@ -383,10 +387,8 @@ 2. Second callout ) { [Fact] - public void ParsesCallouts() => Block!.CallOuts - .Should().NotBeNullOrEmpty() - .And.HaveCount(2) - .And.OnlyContain(c => c.Text.StartsWith('<')); + public void ParsesCallouts() => + Block!.CallOuts.Should().NotBeNullOrEmpty().And.HaveCount(2).And.OnlyContain(c => c.Text.StartsWith('<')); [Fact] public void HandlesCommentBlocksCorrectly() => Collector.Diagnostics.Should().BeEmpty(); @@ -403,13 +405,15 @@ public void RenderedHtmlContainsCallouts() => ); } -public class CodeBlockWithMultipleCommentTypesThenList(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", -""" +public class CodeBlockWithMultipleCommentTypesThenList(ITestOutputHelper output) : CodeBlockCallOutTests( + output, + "csharp", + """ var x = 1; <1> var y = x - 2; var z = y - 2; <2> """, -""" + """ % This is an HTML-style comment that starts with % % TEST[catch:bad_request] @@ -419,21 +423,21 @@ 2. Second callout ) { [Fact] - public void ParsesCallouts() => Block!.CallOuts - .Should().NotBeNullOrEmpty() - .And.HaveCount(2); + public void ParsesCallouts() => Block!.CallOuts.Should().NotBeNullOrEmpty().And.HaveCount(2); [Fact] public void HandlesCommentBlocksCorrectly() => Collector.Diagnostics.Should().BeEmpty(); } -public class CodeBlockWithCommentBlocksParagraphThenList(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", -""" +public class CodeBlockWithCommentBlocksParagraphThenList(ITestOutputHelper output) : CodeBlockCallOutTests( + output, + "csharp", + """ var x = 1; <1> var y = x - 2; var z = y - 2; <2> """, -""" + """ % TEST[s/"basque_keywords",//] % TEST[catch:bad_request] @@ -445,9 +449,7 @@ 2. Second callout ) { [Fact] - public void ParsesCallouts() => Block!.CallOuts - .Should().NotBeNullOrEmpty() - .And.HaveCount(2); + public void ParsesCallouts() => Block!.CallOuts.Should().NotBeNullOrEmpty().And.HaveCount(2); [Fact] public void HandlesCommentBlocksAndParagraphCorrectly() => Collector.Diagnostics.Should().BeEmpty(); @@ -465,13 +467,15 @@ public void RendersIntermediateParagraph() => ); } -public class CodeBlockWithCommentBlocksTwoParagraphsThenList(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", -""" +public class CodeBlockWithCommentBlocksTwoParagraphsThenList(ITestOutputHelper output) : CodeBlockCallOutTests( + output, + "csharp", + """ var x = 1; <1> var y = x - 2; var z = y - 2; <2> """, -""" + """ % TEST[s/"basque_keywords",//] **This is an intermediate paragraph** @@ -484,22 +488,26 @@ 2. Second callout ) { [Fact] - public void ParsesCallouts() => Block!.CallOuts - .Should().NotBeNullOrEmpty() - .And.HaveCount(2); + public void ParsesCallouts() => Block!.CallOuts.Should().NotBeNullOrEmpty().And.HaveCount(2); [Fact] - public void EmitsErrorForTooManyParagraphs() => Collector.Diagnostics.Should().HaveCount(1) - .And.OnlyContain(c => c.Message.StartsWith("More than one content block between code block with annotations and its list")); + public void EmitsErrorForTooManyParagraphs() => + Collector.Diagnostics + .Should() + .HaveCount(1) + .And + .OnlyContain(c => c.Message.StartsWith("More than one content block between code block with annotations and its list")); } -public class CodeBlockWithManyCommentBlocksNoList(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", -""" +public class CodeBlockWithManyCommentBlocksNoList(ITestOutputHelper output) : CodeBlockCallOutTests( + output, + "csharp", + """ var x = 1; <1> var y = x - 2; var z = y - 2; <2> """, -""" + """ % TEST[s/"basque_keywords",//] % TEST[s/\n$/\nstartyaml\n - compare_analyzers: {index: basque_example, first: basque, second: rebuilt_basque}\nendyaml\n/] % TEST[catch:bad_request] @@ -507,22 +515,26 @@ public class CodeBlockWithManyCommentBlocksNoList(ITestOutputHelper output) : Co ) { [Fact] - public void ParsesCallouts() => Block!.CallOuts - .Should().NotBeNullOrEmpty() - .And.HaveCount(2); + public void ParsesCallouts() => Block!.CallOuts.Should().NotBeNullOrEmpty().And.HaveCount(2); [Fact] - public void EmitsErrorForNoList() => Collector.Diagnostics.Should().HaveCount(1) - .And.OnlyContain(c => c.Message.StartsWith("Code block with annotations is not followed by a list")); + public void EmitsErrorForNoList() => + Collector.Diagnostics + .Should() + .HaveCount(1) + .And + .OnlyContain(c => c.Message.StartsWith("Code block with annotations is not followed by a list")); } -public class CodeBlockWithCommentsAfterList(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", -""" +public class CodeBlockWithCommentsAfterList(ITestOutputHelper output) : CodeBlockCallOutTests( + output, + "csharp", + """ var x = 1; <1> var y = x - 2; var z = y - 2; <2> """, -""" + """ 1. First callout 2. Second callout @@ -531,14 +543,11 @@ 2. Second callout ) { [Fact] - public void ParsesCallouts() => Block!.CallOuts - .Should().NotBeNullOrEmpty() - .And.HaveCount(2); + public void ParsesCallouts() => Block!.CallOuts.Should().NotBeNullOrEmpty().And.HaveCount(2); [Fact] public void HandlesCommentsCorrectly() => Collector.Diagnostics.Should().BeEmpty(); [Fact] - public void RenderedHtmlDoesNotContainComments() => - Html.Should().NotContain("basque_keywords"); + public void RenderedHtmlDoesNotContainComments() => Html.Should().NotContain("basque_keywords"); } diff --git a/tests/Elastic.Markdown.Tests/CodeBlocks/CodeBlockArgumentsTests.cs b/tests/Elastic.Markdown.Tests/CodeBlocks/CodeBlockArgumentsTests.cs index a41aa61865..173a62673b 100644 --- a/tests/Elastic.Markdown.Tests/CodeBlocks/CodeBlockArgumentsTests.cs +++ b/tests/Elastic.Markdown.Tests/CodeBlocks/CodeBlockArgumentsTests.cs @@ -2,7 +2,6 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information - using AwesomeAssertions; using Elastic.Markdown.Myst.CodeBlocks; using Elastic.Markdown.Tests.Inline; @@ -53,24 +52,26 @@ public void ParsesPartiallyAndUsesDefaultOtherwise() } } - public abstract class CodeBlockArgumentsTests( ITestOutputHelper output, string language, string arguments, [LanguageInjection("csharp")] string code, [LanguageInjection("markdown")] string? markdown = null -) - : BlockTest(output, - $""" +) : BlockTest( + output, + $""" ```{language} {arguments} {code} ``` {markdown} """ - ); +); -public class DisabledCallouts(ITestOutputHelper output) : CodeBlockArgumentsTests(output, "csharp", "callouts=false", +public class DisabledCallouts(ITestOutputHelper output) : CodeBlockArgumentsTests( + output, + "csharp", + "callouts=false", """ var x = 1; <1> var y = x - 2; @@ -85,7 +86,10 @@ public class DisabledCallouts(ITestOutputHelper output) : CodeBlockArgumentsTest public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class EnabledCallouts(ITestOutputHelper output) : CodeBlockArgumentsTests(output, "csharp", "callouts=true", +public class EnabledCallouts(ITestOutputHelper output) : CodeBlockArgumentsTests( + output, + "csharp", + "callouts=true", """ var x = 1; <1> """, @@ -101,7 +105,10 @@ 1. This is a callout public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class EnabledSubstitutions(ITestOutputHelper output) : CodeBlockArgumentsTests(output, "csharp", "subs=true", +public class EnabledSubstitutions(ITestOutputHelper output) : CodeBlockArgumentsTests( + output, + "csharp", + "subs=true", """ {{a-variable}} """, @@ -117,8 +124,10 @@ 1. This is a callout public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } - -public class DisabledSubstitutions(ITestOutputHelper output) : CodeBlockArgumentsTests(output, "csharp", "subs=false", +public class DisabledSubstitutions(ITestOutputHelper output) : CodeBlockArgumentsTests( + output, + "csharp", + "subs=false", """ {{a-variable}} """, @@ -134,7 +143,10 @@ 1. This is a callout public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class MultipleArguments(ITestOutputHelper output) : CodeBlockArgumentsTests(output, "csharp", "subs=true, callouts=false", +public class MultipleArguments(ITestOutputHelper output) : CodeBlockArgumentsTests( + output, + "csharp", + "subs=true, callouts=false", """ {{a-variable}} <1> """, @@ -144,9 +156,7 @@ 1. This is a callout ) { [Fact] - public void Render() => Html - .Should().Contain("This is a variable") - .And.Contain("<1>"); + public void Render() => Html.Should().Contain("This is a variable").And.Contain("<1>"); [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); diff --git a/tests/Elastic.Markdown.Tests/CodeBlocks/CodeTests.cs b/tests/Elastic.Markdown.Tests/CodeBlocks/CodeTests.cs index 78aca557ed..4b3d9b2dc2 100644 --- a/tests/Elastic.Markdown.Tests/CodeBlocks/CodeTests.cs +++ b/tests/Elastic.Markdown.Tests/CodeBlocks/CodeTests.cs @@ -8,9 +8,9 @@ namespace Elastic.Markdown.Tests.CodeBlocks; -public abstract class CodeBlockTests(ITestOutputHelper output, string directive, string? language = null) - : BlockTest(output, -$$""" +public abstract class CodeBlockTests(ITestOutputHelper output, string directive, string? language = null) : BlockTest( + output, + $$""" ```{{directive}} {{language}} var x = 1; ``` @@ -45,4 +45,3 @@ public class RawMarkdownCodeBlockTests(ITestOutputHelper output) : CodeBlockTest [Fact] public void SetsLanguage() => Block!.Language.Should().Be("javascript"); } - diff --git a/tests/Elastic.Markdown.Tests/CodeBlocks/ConsoleCodeBlockTests.cs b/tests/Elastic.Markdown.Tests/CodeBlocks/ConsoleCodeBlockTests.cs index f19bed2051..298ce2e51f 100644 --- a/tests/Elastic.Markdown.Tests/CodeBlocks/ConsoleCodeBlockTests.cs +++ b/tests/Elastic.Markdown.Tests/CodeBlocks/ConsoleCodeBlockTests.cs @@ -12,8 +12,7 @@ namespace Elastic.Markdown.Tests.CodeBlocks; public abstract class ConsoleCodeBlockTests( ITestOutputHelper output, [LanguageInjection("markdown")] string markdown -) - : BlockTest(output, markdown) +) : BlockTest(output, markdown) { [Fact] public void ParsesConsoleCodeBlock() => Block.Should().NotBeNull(); @@ -22,8 +21,9 @@ public abstract class ConsoleCodeBlockTests( public void SetsLanguage() => Block!.Language.Should().Be("json"); } -public class SingleConsoleApiCallTests(ITestOutputHelper output) : ConsoleCodeBlockTests(output, -""" +public class SingleConsoleApiCallTests(ITestOutputHelper output) : ConsoleCodeBlockTests( + output, + """ ```console GET /mydocuments/_search { @@ -55,8 +55,9 @@ public void CreatesSingleApiSegment() public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); } -public class MultipleConsoleApiCallsTests(ITestOutputHelper output) : ConsoleCodeBlockTests(output, -""" +public class MultipleConsoleApiCallsTests(ITestOutputHelper output) : ConsoleCodeBlockTests( + output, + """ ```console GET /mydocuments/_search { @@ -105,8 +106,9 @@ public void CreatesMultipleApiSegments() public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); } -public class ConsoleWithDifferentHttpVerbsTests(ITestOutputHelper output) : ConsoleCodeBlockTests(output, -""" +public class ConsoleWithDifferentHttpVerbsTests(ITestOutputHelper output) : ConsoleCodeBlockTests( + output, + """ ```console GET /api/users { @@ -138,8 +140,9 @@ public void HandlesDifferentHttpVerbs() public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); } -public class ConsoleWithCalloutsTests(ITestOutputHelper output) : ConsoleCodeBlockTests(output, -""" +public class ConsoleWithCalloutsTests(ITestOutputHelper output) : ConsoleCodeBlockTests( + output, + """ ```console GET /mydocuments/_search { @@ -171,8 +174,9 @@ public void CreatesMultipleApiSegmentsWithCallouts() public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); } -public class ConsoleWithEmptyLinesTests(ITestOutputHelper output) : ConsoleCodeBlockTests(output, -""" +public class ConsoleWithEmptyLinesTests(ITestOutputHelper output) : ConsoleCodeBlockTests( + output, + """ ```console GET /api/test { @@ -199,8 +203,9 @@ public void HandlesEmptyLinesBetweenApiCalls() public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); } -public class ConsoleWithOnlyHeadersTests(ITestOutputHelper output) : ConsoleCodeBlockTests(output, -""" +public class ConsoleWithOnlyHeadersTests(ITestOutputHelper output) : ConsoleCodeBlockTests( + output, + """ ```console GET /api/health POST /api/status @@ -224,8 +229,9 @@ public void HandlesApiCallsWithoutBodies() public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); } -public class ConsoleWithCalloutsOnHttpVerbsTests(ITestOutputHelper output) : ConsoleCodeBlockTests(output, -""" +public class ConsoleWithCalloutsOnHttpVerbsTests(ITestOutputHelper output) : ConsoleCodeBlockTests( + output, + """ ```console GET /api/users <1> { @@ -281,8 +287,9 @@ public void RendersCalloutHtmlInConsoleCodeBlocks() public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); } -public class ConsoleWithCalloutsInJsonContentTests(ITestOutputHelper output) : ConsoleCodeBlockTests(output, -""" +public class ConsoleWithCalloutsInJsonContentTests(ITestOutputHelper output) : ConsoleCodeBlockTests( + output, + """ ```console PUT my-index-000001 { @@ -346,8 +353,9 @@ public void RendersCalloutsInJsonContent() public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); } -public class ConsoleWithHtmlCharsTests(ITestOutputHelper output) : ConsoleCodeBlockTests(output, -""" +public class ConsoleWithHtmlCharsTests(ITestOutputHelper output) : ConsoleCodeBlockTests( + output, + """ ```console POST /auth/login { @@ -420,4 +428,3 @@ public void EscapesHtmlCharsInHeader() [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); } - diff --git a/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs b/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs index 18e7469627..7abaeae8fa 100644 --- a/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs @@ -18,15 +18,15 @@ public class CodexHtmxCrossLinkTests(ITestOutputHelper output) : LinkTestBase(ou protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, - IConfigurationContext configurationContext) => + IConfigurationContext configurationContext + ) => new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/r/codex-environments", BuildType = BuildType.Codex }; - protected override ICrossLinkResolver CreateCrossLinkResolver() => - new TestCodexCrossLinkResolver(useRelativePaths: true); + protected override ICrossLinkResolver CreateCrossLinkResolver() => new TestCodexCrossLinkResolver(useRelativePaths: true); [Fact] public void CrossLink_ProducesPathOnlyHref() @@ -43,8 +43,7 @@ public void CrossLink_HasNoSelectOobButKeepsPreload() } [Fact] - public void CrossLink_NoTargetBlank() => - Html.Should().NotContain("target=\"_blank\""); + public void CrossLink_NoTargetBlank() => Html.Should().NotContain("target=\"_blank\""); [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); @@ -56,27 +55,24 @@ public class IsolatedCodexCrossLinkTests(ITestOutputHelper output) : LinkTestBas protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, - IConfigurationContext configurationContext) => + IConfigurationContext configurationContext + ) => new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs", BuildType = BuildType.Isolated }; - protected override ICrossLinkResolver CreateCrossLinkResolver() => - new TestCodexCrossLinkResolver(useRelativePaths: false); + protected override ICrossLinkResolver CreateCrossLinkResolver() => new TestCodexCrossLinkResolver(useRelativePaths: false); [Fact] - public void IsolatedCrossLink_HasAbsoluteHref() => - Html.Should().Contain("https://codex.elastic.dev/r/kibana/"); + public void IsolatedCrossLink_HasAbsoluteHref() => Html.Should().Contain("https://codex.elastic.dev/r/kibana/"); [Fact] - public void IsolatedCrossLink_HasTargetBlank() => - Html.Should().Contain("target=\"_blank\""); + public void IsolatedCrossLink_HasTargetBlank() => Html.Should().Contain("target=\"_blank\""); [Fact] - public void IsolatedCrossLink_NoHtmx() => - Html.Should().NotContain("hx-select-oob"); + public void IsolatedCrossLink_NoHtmx() => Html.Should().NotContain("hx-select-oob"); [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); diff --git a/tests/Elastic.Markdown.Tests/CrossLinks/UriEnvironmentResolverTests.cs b/tests/Elastic.Markdown.Tests/CrossLinks/UriEnvironmentResolverTests.cs index 74185b46ce..8e530f47bd 100644 --- a/tests/Elastic.Markdown.Tests/CrossLinks/UriEnvironmentResolverTests.cs +++ b/tests/Elastic.Markdown.Tests/CrossLinks/UriEnvironmentResolverTests.cs @@ -15,16 +15,12 @@ namespace Elastic.Markdown.Tests.CrossLinks; /// Mirrors the path extraction logic in CodexBuildService.CollectRedirects. internal static class RedirectPathExtractor { - public static string GetPath(Uri? uri) => - uri is null - ? string.Empty - : uri.IsAbsoluteUri ? uri.AbsolutePath : uri.OriginalString; + public static string GetPath(Uri? uri) => uri is null ? string.Empty : uri.IsAbsoluteUri ? uri.AbsolutePath : uri.OriginalString; } public class CodexAwareUriResolverTests { - private static readonly FrozenSet CodexRepos = - new HashSet { "observability-robots", "docs-eng-team" }.ToFrozenSet(); + private static readonly FrozenSet CodexRepos = new HashSet { "observability-robots", "docs-eng-team" }.ToFrozenSet(); [Fact] public void CodexRepo_RelativeMode_ProducesPathOnly() @@ -276,7 +272,9 @@ out _ success.Should().BeFalse(); emittedError.Should().NotBeNull(); - emittedError.Should().Contain("https://github.com/elastic/codex-link-index/blob/main/internal/elastic/platform-observability-team/links.json"); + emittedError.Should().Contain( + "https://github.com/elastic/codex-link-index/blob/main/internal/elastic/platform-observability-team/links.json" + ); emittedError.Should().NotContain("/main/links.json"); } diff --git a/tests/Elastic.Markdown.Tests/DetectionRules/DetectionRuleParsingTests.cs b/tests/Elastic.Markdown.Tests/DetectionRules/DetectionRuleParsingTests.cs index e9fc329e03..3fac5665b9 100644 --- a/tests/Elastic.Markdown.Tests/DetectionRules/DetectionRuleParsingTests.cs +++ b/tests/Elastic.Markdown.Tests/DetectionRules/DetectionRuleParsingTests.cs @@ -9,7 +9,8 @@ namespace Elastic.Markdown.Tests.DetectionRules; public class DetectionRuleParsingTests { - private const string MinimalRule = """ + private const string MinimalRule = + """ [metadata] creation_date = "2024/08/01" maturity = "production" @@ -43,7 +44,8 @@ public void FromToml_MinimalRule_ParsesCorrectly() [Fact] public void FromToml_ImplicitIntermediateTable_ParsesTransformInvestigate() { - var toml = MinimalRule + """ + var toml = MinimalRule + + """ [[transform.investigate]] label = "Alerts associated with the user" @@ -79,7 +81,8 @@ public void FromToml_ImplicitIntermediateTable_ParsesTransformInvestigate() public void FromToml_MultiLineStringWithMarkdownLinks_ParsesCorrectly() { // TOML uses """ for multi-line strings; use 4-quote C# raw literals to embed them - var toml = """" + var toml = + """" [metadata] creation_date = "2024/08/01" maturity = "production" @@ -113,7 +116,8 @@ Also see [another link](https://example.com). public void FromToml_MixedMultiLineDelimiters_ParsesCorrectly() { // Triple-quoted """ appears inside a '''-delimited multi-line string - var toml = """" + var toml = + """" [metadata] creation_date = "2024/08/01" maturity = "production" @@ -146,7 +150,8 @@ Check the process tree.""" [Fact] public void FromToml_DeprecatedRule_ParsesDeprecationDate() { - var toml = """ + var toml = + """ [metadata] creation_date = "2024/08/01" deprecation_date = "2025/03/15" @@ -172,7 +177,8 @@ public void FromToml_DeprecatedRule_ParsesDeprecationDate() [Fact] public void FromToml_ThreatWithSubTechniques_ParsesFullHierarchy() { - var toml = MinimalRule + """ + var toml = MinimalRule + + """ [[rule.threat]] framework = "MITRE ATT&CK" @@ -207,7 +213,8 @@ public void FromToml_ThreatWithSubTechniques_ParsesFullHierarchy() [Fact] public void FromToml_MultipleThreats_ParsesAll() { - var toml = MinimalRule + """ + var toml = MinimalRule + + """ [[rule.threat]] framework = "MITRE ATT&CK" @@ -250,7 +257,8 @@ public void FromToml_OptionalFieldsMissing_DefaultsCorrectly() [Fact] public void FromToml_DomainTag_ExtractedCorrectly() { - var toml = """ + var toml = + """ [metadata] creation_date = "2024/08/01" maturity = "production" diff --git a/tests/Elastic.Markdown.Tests/Directives/AdmonitionTests.cs b/tests/Elastic.Markdown.Tests/Directives/AdmonitionTests.cs index a19f59e805..d57c5ad6fd 100644 --- a/tests/Elastic.Markdown.Tests/Directives/AdmonitionTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/AdmonitionTests.cs @@ -7,8 +7,9 @@ namespace Elastic.Markdown.Tests.Directives; -public abstract class AdmonitionBaseTests(ITestOutputHelper output, string directive) : DirectiveTest(output, -$$""" +public abstract class AdmonitionBaseTests(ITestOutputHelper output, string directive) : DirectiveTest( + output, + $$""" :::{{{directive}}} This is an attention block ::: @@ -47,8 +48,9 @@ public class ImportantTests(ITestOutputHelper output) : AdmonitionBaseTests(outp public void SetsTitle() => Block!.Title.Should().Be("Important"); } -public class NoteTitleTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class NoteTitleTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ```{note} This is my custom note This is an attention block ``` @@ -63,9 +65,9 @@ A regular paragraph. public void SetsCustomTitle() => Block!.Title.Should().Be("Note This is my custom note"); } - -public class AdmonitionTitleTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class AdmonitionTitleTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ```{admonition} This is my custom title This is an attention block ``` @@ -80,8 +82,9 @@ A regular paragraph. public void SetsCustomTitle() => Block!.Title.Should().Be("This is my custom title"); } -public class DropdownTitleTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class DropdownTitleTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{dropdown} This is my custom dropdown :open: This is an attention block @@ -100,8 +103,9 @@ A regular paragraph. public void SetsDropdownOpen() => Block!.DropdownOpen.Should().BeTrue(); } -public class DropdownPlainTextTitleTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class DropdownPlainTextTitleTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{dropdown} Deprecate `elastic.apm` settings Dropdown body content. ::: @@ -119,8 +123,9 @@ public void RendersPlainTextTitleInHtml() } } -public class DropdownPlainTextBoldTitleTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class DropdownPlainTextBoldTitleTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{dropdown} Disable **Save** button Dropdown body content. ::: @@ -138,8 +143,9 @@ public void RendersBoldTitleAsPlainTextInHtml() } } -public class DropdownPlainTextItalicTitleTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class DropdownPlainTextItalicTitleTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{dropdown} Use _italic_ emphasis Dropdown body content. ::: @@ -157,8 +163,9 @@ public void RendersItalicTitleAsPlainTextInHtml() } } -public class DropdownAppliesToTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class DropdownAppliesToTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{dropdown} This is my custom dropdown :applies_to: stack: ga 9.0 This is an attention block @@ -180,8 +187,9 @@ A regular paragraph. public void ParsesAppliesTo() => Block!.AppliesTo.Should().NotBeNull(); } -public class DropdownPropertyParsingTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class DropdownPropertyParsingTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{dropdown} Test Dropdown :open: :name: test-dropdown @@ -204,8 +212,9 @@ A regular paragraph. public void SetsCrossReferenceName() => Block!.CrossReferenceName.Should().Be("test-dropdown"); } -public class DropdownNestedContentTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class DropdownNestedContentTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ::::{dropdown} Nested Content Test :open: This dropdown contains nested content with colons: @@ -274,8 +283,9 @@ public void ContainsContentAfterNestedDirective() } } -public class DropdownComplexPropertyTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class DropdownComplexPropertyTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{dropdown} Complex Properties Test :applies_to: stack: ga 9.0 This is content with applies_to property @@ -298,8 +308,9 @@ public void ParsesAppliesToWithComplexValue() } } -public class NoteAppliesToTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class NoteAppliesToTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{note} :applies_to: stack: ga This is a note with applies_to information @@ -330,8 +341,9 @@ public void RendersAppliesToInHtml() } } -public class WarningAppliesToTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class WarningAppliesToTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{warning} :applies_to: stack: ga This is a warning with applies_to information @@ -362,8 +374,9 @@ public void RendersAppliesToInHtml() } } -public class TipAppliesToTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class TipAppliesToTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{tip} :applies_to: stack: ga This is a tip with applies_to information @@ -394,8 +407,9 @@ public void RendersAppliesToInHtml() } } -public class ImportantAppliesToTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ImportantAppliesToTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{important} :applies_to: stack: ga This is an important notice with applies_to information @@ -426,8 +440,9 @@ public void RendersAppliesToInHtml() } } -public class AdmonitionAppliesToTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class AdmonitionAppliesToTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{admonition} Custom Admonition :applies_to: stack: ga This is a custom admonition with applies_to information diff --git a/tests/Elastic.Markdown.Tests/Directives/AdmonitionUnsupportedTests.cs b/tests/Elastic.Markdown.Tests/Directives/AdmonitionUnsupportedTests.cs index 4fbd3d7098..a48d2f6bf8 100644 --- a/tests/Elastic.Markdown.Tests/Directives/AdmonitionUnsupportedTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/AdmonitionUnsupportedTests.cs @@ -7,15 +7,15 @@ namespace Elastic.Markdown.Tests.Directives; -public abstract class AdmonitionUnsupportedTests(ITestOutputHelper output, string directive) - : DirectiveTest(output, -$$""" +public abstract class AdmonitionUnsupportedTests(ITestOutputHelper output, string directive) : DirectiveTest( + output, + $$""" :::{{{directive}}} This is an attention block ::: A regular paragraph. """ - ) +) { [Fact] public void ParsesAsUnknown() => Block.Should().NotBeNull(); diff --git a/tests/Elastic.Markdown.Tests/Directives/AgentSkillTests.cs b/tests/Elastic.Markdown.Tests/Directives/AgentSkillTests.cs index df55897c52..8290a0f87e 100644 --- a/tests/Elastic.Markdown.Tests/Directives/AgentSkillTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/AgentSkillTests.cs @@ -7,8 +7,9 @@ namespace Elastic.Markdown.Tests.Directives; -public class AgentSkillTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class AgentSkillTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{agent-skill} :url: https://github.com/elastic/agent-skills@elasticsearch-esql ::: @@ -40,12 +41,10 @@ public void RendersAgentSkillDiv() } [Fact] - public void RendersTitle() => - Html.Should().Contain("Agent skill available"); + public void RendersTitle() => Html.Should().Contain("Agent skill available"); [Fact] - public void RendersDefaultText() => - Html.Should().Contain("A skill is available to help AI agents with this topic."); + public void RendersDefaultText() => Html.Should().Contain("A skill is available to help AI agents with this topic."); [Fact] public void RendersLearnMoreLink() @@ -63,12 +62,12 @@ public void RendersCopyButton() } [Fact] - public void DoesNotRenderLinkButton() => - Html.Should().NotContain("Get the skill"); + public void DoesNotRenderLinkButton() => Html.Should().NotContain("Get the skill"); } -public class AgentSkillWithBodyTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class AgentSkillWithBodyTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{agent-skill} :url: https://github.com/elastic/agent-skills@elasticsearch-esql @@ -79,24 +78,21 @@ A regular paragraph. ) { [Fact] - public void RendersCustomBody() => - Html.Should().Contain("This skill helps agents write and optimize ES|QL queries."); + public void RendersCustomBody() => Html.Should().Contain("This skill helps agents write and optimize ES|QL queries."); [Fact] - public void StillRendersDefaultText() => - Html.Should().Contain("A skill is available to help AI agents with this topic."); + public void StillRendersDefaultText() => Html.Should().Contain("A skill is available to help AI agents with this topic."); [Fact] - public void StillRendersLearnMoreLink() => - Html.Should().Contain("Learn more about agent skills for Elastic"); + public void StillRendersLearnMoreLink() => Html.Should().Contain("Learn more about agent skills for Elastic"); [Fact] - public void StillRendersCopyButton() => - Html.Should().Contain("Copy install command"); + public void StillRendersCopyButton() => Html.Should().Contain("Copy install command"); } -public class AgentSkillMissingUrlTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class AgentSkillMissingUrlTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{agent-skill} ::: A regular paragraph. @@ -104,12 +100,12 @@ A regular paragraph. ) { [Fact] - public void EmitsError() => - Collector.Diagnostics.Should().Contain(d => d.Message.Contains("requires a :url: property")); + public void EmitsError() => Collector.Diagnostics.Should().Contain(d => d.Message.Contains("requires a :url: property")); } -public class AgentSkillRelativeUrlTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class AgentSkillRelativeUrlTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{agent-skill} :url: /relative/path ::: @@ -118,12 +114,12 @@ A regular paragraph. ) { [Fact] - public void EmitsError() => - Collector.Diagnostics.Should().Contain(d => d.Message.Contains("must be an absolute URL")); + public void EmitsError() => Collector.Diagnostics.Should().Contain(d => d.Message.Contains("must be an absolute URL")); } -public class AgentSkillNoSkillNameTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class AgentSkillNoSkillNameTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{agent-skill} :url: https://github.com/elastic/agent-skills ::: diff --git a/tests/Elastic.Markdown.Tests/Directives/ApplicabilitySwitchTests.cs b/tests/Elastic.Markdown.Tests/Directives/ApplicabilitySwitchTests.cs index 2aff82e3c4..3113f3c612 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ApplicabilitySwitchTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ApplicabilitySwitchTests.cs @@ -7,8 +7,9 @@ namespace Elastic.Markdown.Tests.Directives; -public class ApplicabilitySwitchTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ApplicabilitySwitchTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::::{applies-switch} ::::{applies-item} stack: preview 9.1 @@ -46,8 +47,7 @@ public void ParsesApplicabilitySwitchItems() } [Fact] - public void FirstItemRendersChecked() => - Html.Should().Contain("applies-switch-input\" checked=\"checked\""); + public void FirstItemRendersChecked() => Html.Should().Contain("applies-switch-input\" checked=\"checked\""); [Fact] public void ParsesAppliesToDefinitions() @@ -70,8 +70,9 @@ public void SetsCorrectDirectiveType() // Reproduces the real-world case: a % comment block between {applies-switch} and // the first {applies-item} previously pushed all item indices to 1, 2, 3. -public class ApplicabilitySwitchWithCommentTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ApplicabilitySwitchWithCommentTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::::{applies-switch} % TODO: some comment here @@ -98,12 +99,12 @@ public void ItemIndicesAreZeroBased() } [Fact] - public void FirstItemRendersChecked() => - Html.Should().Contain("applies-switch-input\" checked=\"checked\""); + public void FirstItemRendersChecked() => Html.Should().Contain("applies-switch-input\" checked=\"checked\""); } -public class MultipleApplicabilitySwitchTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class MultipleApplicabilitySwitchTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::::{applies-switch} ::::{applies-item} stack: ga 8.11 Content for GA version @@ -138,8 +139,9 @@ public void ParsesMultipleApplicabilitySwitches() } } -public class GroupApplicabilitySwitchTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class GroupApplicabilitySwitchTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ::::{applies-switch} :::{applies-item} stack: ga 8.11 Content for GA version @@ -242,8 +244,11 @@ public void GeneratesConsistentSyncKeysForYamlObjects() var testCases = new[] { ("stack: ga 9.1", "stack: ga 9.1"), // Same format should produce same key + ("{ ece: all, ess: all }", "deployment: { ece: all, ess: all }"), // YAML object vs deployment object + ("{ stack: ga 9.1 }", "stack: ga 9.1"), // YAML object vs simple syntax + ("{ deployment: { ece: ga 9.0, ess: ga 9.1 } }", "deployment: { ece: ga 9.0, ess: ga 9.1 }"), // Nested YAML objects }; @@ -251,7 +256,10 @@ public void GeneratesConsistentSyncKeysForYamlObjects() { var key1 = AppliesItemBlock.GenerateSyncKey(yamlObject, Block!.Build.ProductsConfiguration); var key2 = AppliesItemBlock.GenerateSyncKey(equivalentSyntax, Block!.Build.ProductsConfiguration); - key1.Should().Be(key2, $"Sync keys should be the same for YAML object '{yamlObject}' and equivalent syntax '{equivalentSyntax}'"); + key1.Should().Be( + key2, + $"Sync keys should be the same for YAML object '{yamlObject}' and equivalent syntax '{equivalentSyntax}'" + ); // Also verify the key has the expected format key1.Should().StartWith("applies-", "Sync key should start with 'applies-' prefix"); @@ -266,7 +274,10 @@ public void GeneratesDeterministicSyncKeysAcrossMultipleRuns() { // These are the actual SHA256-based hashes that should never change // (unless the version format actually changes) - { "stack: ga 9.1", "applies-A8B9CC9C" }, + { + "stack: ga 9.1", + "applies-A8B9CC9C" + }, { "stack: preview 9.0", "applies-66AECC4E" }, { "ess: ga 8.11", "applies-9CA8543E" }, { "deployment: { ece: ga 9.0, ess: ga 9.1 }", "applies-51C670D4" }, @@ -277,22 +288,22 @@ public void GeneratesDeterministicSyncKeysAcrossMultipleRuns() { var actualKey = AppliesItemBlock.GenerateSyncKey(definition, Block!.Build.ProductsConfiguration); - actualKey.Should().Be(expectedKey, + actualKey.Should().Be( + expectedKey, $"The sync key for '{definition}' must match the expected value. " + - $"If this fails, the hash algorithm has changed and will break sync IDs across builds!"); + $"If this fails, the hash algorithm has changed and will break sync IDs across builds!" + ); // Also verify multiple invocations in this run produce the same key var keys = Enumerable.Range(0, 5) .Select(_ => AppliesItemBlock.GenerateSyncKey(definition, Block!.Build.ProductsConfiguration)) .ToList(); - keys.Distinct().Should().HaveCount(1, - $"All invocations for '{definition}' should produce identical keys"); + keys.Distinct().Should().HaveCount(1, $"All invocations for '{definition}' should produce identical keys"); } // Verify that different definitions produce different keys var allKeys = expectedKeys.Values.ToList(); - allKeys.Distinct().Should().HaveCount(expectedKeys.Count, - "Different applies_to definitions must produce different sync keys"); + allKeys.Distinct().Should().HaveCount(expectedKeys.Count, "Different applies_to definitions must produce different sync keys"); } } diff --git a/tests/Elastic.Markdown.Tests/Directives/ButtonTests.cs b/tests/Elastic.Markdown.Tests/Directives/ButtonTests.cs index c4df993c3c..00021d61d2 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ButtonTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ButtonTests.cs @@ -7,8 +7,9 @@ namespace Elastic.Markdown.Tests.Directives; -public class ButtonBlockTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ButtonBlockTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{button} [Get Started](/get-started) ::: @@ -34,8 +35,9 @@ [Get Started](/get-started) public void RendersButtonText() => Html.Should().Contain("Get Started"); } -public class ButtonSecondaryTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ButtonSecondaryTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{button} :type: secondary [Learn More](/learn-more) @@ -50,8 +52,9 @@ [Learn More](/learn-more) public void RendersSecondaryClass() => Html.Should().Contain("doc-button-secondary"); } -public class ButtonNeutralTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ButtonNeutralTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{button} :type: neutral [Browse All Docs](https://www.elastic.co/docs) @@ -69,8 +72,9 @@ [Browse All Docs](https://www.elastic.co/docs) public void EmitsNoErrors() => Collector.Diagnostics.Should().BeEmpty(); } -public class ButtonNeutralVariantAliasTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ButtonNeutralVariantAliasTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{button} :variant: neutral [Browse All Docs](https://www.elastic.co/docs) @@ -82,8 +86,9 @@ [Browse All Docs](https://www.elastic.co/docs) public void ParsesNeutralTypeFromVariantAlias() => Block!.Type.Should().Be("neutral"); } -public class ButtonNeutralInGroupTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ButtonNeutralInGroupTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ::::{button-group} :::{button} [Get Started](/get-started) @@ -97,15 +102,15 @@ [Get Started](/get-started) ) { [Fact] - public void RendersNeutralItemInsideGroup() => - Html.Should().Contain("class=\"doc-button-item doc-button-neutral\""); + public void RendersNeutralItemInsideGroup() => Html.Should().Contain("class=\"doc-button-item doc-button-neutral\""); [Fact] public void RendersPrimaryAlongsideNeutral() => Html.Should().Contain("doc-button-primary"); } -public class ButtonAlignmentTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ButtonAlignmentTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{button} :align: center [Centered Button](/centered) @@ -120,8 +125,9 @@ [Centered Button](/centered) public void RendersWrapperWithAlignClass() => Html.Should().Contain("doc-button-wrapper doc-button-primary doc-button-center"); } -public class ButtonExternalTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ButtonExternalTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{button} [GitHub](https://github.com/elastic) ::: @@ -135,8 +141,9 @@ public class ButtonExternalTests(ITestOutputHelper output) : DirectiveTest Html.Should().Contain("rel=\"noopener noreferrer\""); } -public class ButtonReferenceLinkTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ButtonReferenceLinkTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{button} [Open][kibana-url] ::: @@ -155,8 +162,9 @@ public class ButtonReferenceLinkTests(ITestOutputHelper output) : DirectiveTest< public void EmitsNoErrors() => Collector.Diagnostics.Should().BeEmpty(); } -public class ButtonInvalidTypeTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ButtonInvalidTypeTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{button} :type: invalid [Invalid Type](/test) @@ -172,8 +180,9 @@ public void EmitsWarningForInvalidType() => public void FallsBackToPrimary() => Block!.Type.Should().Be("primary"); } -public class ButtonGroupTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ButtonGroupTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ::::{button-group} :::{button} :type: primary @@ -194,8 +203,7 @@ public class ButtonGroupTests(ITestOutputHelper output) : DirectiveTest Html.Should().Contain("class=\"doc-button-group"); [Fact] - public void ContainsBothButtons() => - Html.Should().Contain("Primary").And.Contain("Secondary"); + public void ContainsBothButtons() => Html.Should().Contain("Primary").And.Contain("Secondary"); [Fact] public void RendersPrimaryButton() => Html.Should().Contain("doc-button-primary"); @@ -204,8 +212,9 @@ public void ContainsBothButtons() => public void RendersSecondaryButton() => Html.Should().Contain("doc-button-secondary"); } -public class ButtonGroupAlignmentTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ButtonGroupAlignmentTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ::::{button-group} :align: center :::{button} @@ -222,8 +231,9 @@ public class ButtonGroupAlignmentTests(ITestOutputHelper output) : DirectiveTest public void RendersGroupAlignClass() => Html.Should().Contain("doc-button-group-center"); } -public class ButtonInGroupTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ButtonInGroupTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ::::{button-group} :::{button} [In Group](/in-group) @@ -242,8 +252,9 @@ [In Group](/in-group) public void RendersButtonItem() => Html.Should().Contain("doc-button-item"); } -public class ButtonCrossLinkTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ButtonCrossLinkTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{button} [Kibana Docs](kibana://api/index.md) ::: @@ -257,8 +268,9 @@ [Kibana Docs](kibana://api/index.md) public void RendersLinkHref() => Html.Should().Contain("href=\""); } -public class ButtonCursorProtocolTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ButtonCursorProtocolTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{button} [Install with Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=elastic&config=eyJmb28iOiJiYXIifQ==) ::: @@ -275,8 +287,9 @@ [Install with Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=elasti public void EmitsNoErrors() => Collector.Diagnostics.Should().BeEmpty(); } -public class ButtonVscodeProtocolTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ButtonVscodeProtocolTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{button} [Install with VS Code](vscode:extension/elastic.elasticsearch) ::: @@ -293,8 +306,9 @@ [Install with VS Code](vscode:extension/elastic.elasticsearch) public void EmitsNoErrors() => Collector.Diagnostics.Should().BeEmpty(); } -public class ButtonVscodeInsidersProtocolTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ButtonVscodeInsidersProtocolTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{button} [Install with VS Code Insiders](vscode-insiders:mcp/install?%7B%22name%22%3A%22oblt-cli%22%7D) ::: @@ -311,20 +325,18 @@ [Install with VS Code Insiders](vscode-insiders:mcp/install?%7B%22name%22%3A%22o public void EmitsNoErrors() => Collector.Diagnostics.Should().BeEmpty(); } -public class ButtonEmptyTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ButtonEmptyTests(ITestOutputHelper output) : DirectiveTest(output, """ :::{button} ::: -""" -) +""") { [Fact] - public void EmitsErrorForEmptyContent() => - Collector.Diagnostics.Should().ContainSingle(d => d.Message.Contains("requires a link")); + public void EmitsErrorForEmptyContent() => Collector.Diagnostics.Should().ContainSingle(d => d.Message.Contains("requires a link")); } -public class ButtonPlainTextTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ButtonPlainTextTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{button} Just some text without a link ::: @@ -336,8 +348,9 @@ public void EmitsErrorForPlainText() => Collector.Diagnostics.Should().ContainSingle(d => d.Message.Contains("must contain only a single Markdown link")); } -public class ButtonMultipleLinksTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ButtonMultipleLinksTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{button} [Link One](/one) and [Link Two](/two) ::: @@ -349,8 +362,9 @@ public void EmitsErrorForMultipleLinks() => Collector.Diagnostics.Should().ContainSingle(d => d.Message.Contains("must contain only a single Markdown link")); } -public class ButtonNestedDirectiveTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ButtonNestedDirectiveTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{button} ::::{note} This is nested diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogAnchorNavigationTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogAnchorNavigationTests.cs index e30873a7b5..53b33cbee6 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogAnchorNavigationTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogAnchorNavigationTests.cs @@ -17,14 +17,19 @@ namespace Elastic.Markdown.Tests.Directives; ///
public class ChangelogYearMonthAnchorNavigationTests : DirectiveTest { - public ChangelogYearMonthAnchorNavigationTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogYearMonthAnchorNavigationTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => FileSystem.AddFile("docs/changelog/bundles/2025-11.yaml", new MockFileData( - // language=yaml - """ + """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/2025-11.yaml", + new MockFileData( + // language=yaml + """ products: - product: cloud-hosted target: 2025-11 @@ -43,7 +48,9 @@ public ChangelogYearMonthAnchorNavigationTests(ITestOutputHelper output) : base( target: 2025-11 prs: - "222222" - """)); + """ + ) + ); [Fact] public void VersionHeadingTocSlugIsSlugifiedDisplayName() @@ -63,8 +70,10 @@ public void VersionHeadingHtmlIdMatchesTocSlug() var toc = Block!.GeneratedTableOfContent.ToList(); var versionItem = toc.Single(t => t.Level == 2); - Html.Should().Contain($"id=\"{versionItem.Slug}\"", - $"heading-wrapper id must match TOC slug '{versionItem.Slug}' so the right-nav link scrolls to the section"); + Html.Should().Contain( + $"id=\"{versionItem.Slug}\"", + $"heading-wrapper id must match TOC slug '{versionItem.Slug}' so the right-nav link scrolls to the section" + ); } [Fact] @@ -73,8 +82,10 @@ public void SubSectionTocSlugsHaveMatchingHtmlIds() var toc = Block!.GeneratedTableOfContent.ToList(); foreach (var item in toc.Where(t => t.Level == 3)) { - Html.Should().Contain($"id=\"{item.Slug}\"", - $"sub-section TOC item '{item.Heading}' (slug '{item.Slug}') must have a matching heading-wrapper id"); + Html.Should().Contain( + $"id=\"{item.Slug}\"", + $"sub-section TOC item '{item.Heading}' (slug '{item.Slug}') must have a matching heading-wrapper id" + ); } } @@ -84,8 +95,10 @@ public void AllTocSlugsHaveMatchingHtmlIds() var toc = Block!.GeneratedTableOfContent.ToList(); foreach (var item in toc) { - Html.Should().Contain($"id=\"{item.Slug}\"", - $"TOC item '{item.Heading}' (slug '{item.Slug}') must have a matching heading-wrapper id in the rendered HTML"); + Html.Should().Contain( + $"id=\"{item.Slug}\"", + $"TOC item '{item.Heading}' (slug '{item.Slug}') must have a matching heading-wrapper id in the rendered HTML" + ); } } @@ -97,8 +110,7 @@ public void SubSectionSlugsUseYearMonthKeyNotDisplayName() // sub-section slugs must contain "2025-11", not "november-2025". var toc = Block!.GeneratedTableOfContent.ToList(); foreach (var item in toc.Where(t => t.Level == 3)) - item.Slug.Should().Contain("2025-11", - $"sub-section slug for a yyyy-MM bundle should retain the original date key"); + item.Slug.Should().Contain("2025-11", $"sub-section slug for a yyyy-MM bundle should retain the original date key"); } } @@ -110,14 +122,19 @@ public void SubSectionSlugsUseYearMonthKeyNotDisplayName() ///
public class ChangelogFullDateAnchorNavigationTests : DirectiveTest { - public ChangelogFullDateAnchorNavigationTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogFullDateAnchorNavigationTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => FileSystem.AddFile("docs/changelog/bundles/2025-08-05.yaml", new MockFileData( - // language=yaml - """ + """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/2025-08-05.yaml", + new MockFileData( + // language=yaml + """ products: - product: cloud-serverless target: 2025-08-05 @@ -139,7 +156,9 @@ public ChangelogFullDateAnchorNavigationTests(ITestOutputHelper output) : base(o action: Take action. prs: - "222222" - """)); + """ + ) + ); [Fact] public void VersionHeadingTocSlugIsSlugifiedDisplayName() @@ -158,8 +177,7 @@ public void VersionHeadingHtmlIdMatchesTocSlug() var toc = Block!.GeneratedTableOfContent.ToList(); var versionItem = toc.Single(t => t.Level == 2); - Html.Should().Contain($"id=\"{versionItem.Slug}\"", - $"heading-wrapper id must match TOC slug '{versionItem.Slug}'"); + Html.Should().Contain($"id=\"{versionItem.Slug}\"", $"heading-wrapper id must match TOC slug '{versionItem.Slug}'"); } [Fact] @@ -168,8 +186,10 @@ public void AllTocSlugsHaveMatchingHtmlIds() var toc = Block!.GeneratedTableOfContent.ToList(); foreach (var item in toc) { - Html.Should().Contain($"id=\"{item.Slug}\"", - $"TOC item '{item.Heading}' (slug '{item.Slug}') must have a matching heading-wrapper id"); + Html.Should().Contain( + $"id=\"{item.Slug}\"", + $"TOC item '{item.Heading}' (slug '{item.Slug}') must have a matching heading-wrapper id" + ); } } } @@ -184,14 +204,19 @@ public void AllTocSlugsHaveMatchingHtmlIds() ///
public class ChangelogSemverAnchorNavigationTests : DirectiveTest { - public ChangelogSemverAnchorNavigationTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogSemverAnchorNavigationTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -220,7 +245,9 @@ public ChangelogSemverAnchorNavigationTests(ITestOutputHelper output) : base(out action: Do this. prs: - "333333" - """)); + """ + ) + ); [Fact] public void VersionHeadingTocSlugMatchesDisplayVersion() @@ -239,8 +266,7 @@ public void VersionHeadingHtmlIdMatchesTocSlug() var toc = Block!.GeneratedTableOfContent.ToList(); var versionItem = toc.Single(t => t.Level == 2); - Html.Should().Contain($"id=\"{versionItem.Slug}\"", - $"heading-wrapper id must match TOC slug '{versionItem.Slug}'"); + Html.Should().Contain($"id=\"{versionItem.Slug}\"", $"heading-wrapper id must match TOC slug '{versionItem.Slug}'"); } [Fact] @@ -248,8 +274,7 @@ public void SubSectionTocSlugsContainVersionString() { var toc = Block!.GeneratedTableOfContent.ToList(); foreach (var item in toc.Where(t => t.Level == 3)) - item.Slug.Should().Contain("9.3.0", - $"sub-section slug should contain the version string — Slugify.Core preserves dots"); + item.Slug.Should().Contain("9.3.0", $"sub-section slug should contain the version string — Slugify.Core preserves dots"); } [Fact] @@ -257,8 +282,10 @@ public void AllTocSlugsHaveMatchingHtmlIds() { var toc = Block!.GeneratedTableOfContent.ToList(); foreach (var item in toc) - Html.Should().Contain($"id=\"{item.Slug}\"", - $"TOC item '{item.Heading}' (slug '{item.Slug}') must have a matching heading-wrapper id"); + Html.Should().Contain( + $"id=\"{item.Slug}\"", + $"TOC item '{item.Heading}' (slug '{item.Slug}') must have a matching heading-wrapper id" + ); } [Fact] @@ -267,8 +294,7 @@ public void GeneratedAnchorsContainVersionString() // Slugify.Core preserves dots, so sub-section anchors retain "9.3.0". var anchors = Block!.GeneratedAnchors.ToList(); foreach (var anchor in anchors) - anchor.Should().Contain("9.3.0", - $"generated anchor '{anchor}' should contain the semver version string"); + anchor.Should().Contain("9.3.0", $"generated anchor '{anchor}' should contain the semver version string"); } } @@ -281,14 +307,19 @@ public void GeneratedAnchorsContainVersionString() ///
public class ChangelogRawVersionAnchorNavigationTests : DirectiveTest { - public ChangelogRawVersionAnchorNavigationTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogRawVersionAnchorNavigationTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => FileSystem.AddFile("docs/changelog/bundles/release-alpha.yaml", new MockFileData( - // language=yaml - """ + """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/release-alpha.yaml", + new MockFileData( + // language=yaml + """ products: - product: experimental target: release-alpha @@ -300,7 +331,9 @@ public ChangelogRawVersionAnchorNavigationTests(ITestOutputHelper output) : base target: release-alpha prs: - "111111" - """)); + """ + ) + ); [Fact] public void VersionHeadingTocSlugMatchesRawVersion() @@ -318,8 +351,7 @@ public void VersionHeadingHtmlIdMatchesTocSlug() var toc = Block!.GeneratedTableOfContent.ToList(); var versionItem = toc.Single(t => t.Level == 2); - Html.Should().Contain($"id=\"{versionItem.Slug}\"", - $"heading-wrapper id must match TOC slug '{versionItem.Slug}'"); + Html.Should().Contain($"id=\"{versionItem.Slug}\"", $"heading-wrapper id must match TOC slug '{versionItem.Slug}'"); } [Fact] @@ -328,8 +360,10 @@ public void AllTocSlugsHaveMatchingHtmlIds() var toc = Block!.GeneratedTableOfContent.ToList(); foreach (var item in toc) { - Html.Should().Contain($"id=\"{item.Slug}\"", - $"TOC item '{item.Heading}' (slug '{item.Slug}') must have a matching heading-wrapper id"); + Html.Should().Contain( + $"id=\"{item.Slug}\"", + $"TOC item '{item.Heading}' (slug '{item.Slug}') must have a matching heading-wrapper id" + ); } } } @@ -343,16 +377,20 @@ public void AllTocSlugsHaveMatchingHtmlIds() ///
public class ChangelogMultiVersionAnchorNavigationTests : DirectiveTest { - public ChangelogMultiVersionAnchorNavigationTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogMultiVersionAnchorNavigationTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) + """ + ) { - FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -364,11 +402,15 @@ public ChangelogMultiVersionAnchorNavigationTests(ITestOutputHelper output) : ba target: 9.3.0 prs: - "111111" - """)); - - FileSystem.AddFile("docs/changelog/bundles/2025-11.yaml", new MockFileData( - // language=yaml """ + ) + ); + + FileSystem.AddFile( + "docs/changelog/bundles/2025-11.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 2025-11 @@ -380,7 +422,9 @@ public ChangelogMultiVersionAnchorNavigationTests(ITestOutputHelper output) : ba target: 2025-11 prs: - "222222" - """)); + """ + ) + ); } [Fact] @@ -389,8 +433,10 @@ public void AllTocSlugsHaveMatchingHtmlIds() var toc = Block!.GeneratedTableOfContent.ToList(); foreach (var item in toc) { - Html.Should().Contain($"id=\"{item.Slug}\"", - $"TOC item '{item.Heading}' (slug '{item.Slug}') must have a matching heading-wrapper id"); + Html.Should().Contain( + $"id=\"{item.Slug}\"", + $"TOC item '{item.Heading}' (slug '{item.Slug}') must have a matching heading-wrapper id" + ); } } diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs index acb5c13fe7..70ef198aac 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs @@ -15,16 +15,20 @@ namespace Elastic.Markdown.Tests.Directives; public class ChangelogBasicTests : DirectiveTest { - public ChangelogBasicTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogBasicTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => + """ + ) => // Create the default bundles folder with a test bundle - FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -49,7 +53,9 @@ public ChangelogBasicTests(ITestOutputHelper output) : base(output, - Indexing prs: - "123457" - """)); + """ + ) + ); [Fact] public void ParsesChangelogBlock() => Block.Should().NotBeNull(); @@ -61,7 +67,8 @@ public ChangelogBasicTests(ITestOutputHelper output) : base(output, public void FindsBundlesFolder() => Block!.Found.Should().BeTrue(); [Fact] - public void SetsCorrectBundlesFolderPath() => Block!.BundlesFolderPath.Should().EndWith("changelog/bundles".Replace('/', Path.DirectorySeparatorChar)); + public void SetsCorrectBundlesFolderPath() => + Block!.BundlesFolderPath.Should().EndWith("changelog/bundles".Replace('/', Path.DirectorySeparatorChar)); [Fact] public void LoadsBundles() => Block!.LoadedBundles.Should().HaveCount(1); @@ -79,16 +86,20 @@ public void RendersMarkdownContent() public class ChangelogExcludeAmendTests : DirectiveTest { - public ChangelogExcludeAmendTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogExcludeAmendTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) + """ + ) { - FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -114,15 +125,21 @@ public ChangelogExcludeAmendTests(ITestOutputHelper output) : base(output, checksum: excluded prs: - "123457" - """)); - FileSystem.AddFile("docs/changelog/bundles/9.3.0.amend-1.yaml", new MockFileData( - // language=yaml """ + ) + ); + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.amend-1.yaml", + new MockFileData( + // language=yaml + """ exclude-entries: - file: name: removed.yaml checksum: excluded - """)); + """ + ) + ); } [Fact] @@ -143,17 +160,21 @@ public void LoadsMergedEntryCount() public class ChangelogMultipleBundlesTests : DirectiveTest { - public ChangelogMultipleBundlesTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogMultipleBundlesTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) + """ + ) { // Create multiple bundles with different versions - FileSystem.AddFile("docs/changelog/bundles/9.2.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/9.2.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.2.0 @@ -165,11 +186,15 @@ public ChangelogMultipleBundlesTests(ITestOutputHelper output) : base(output, target: 9.2.0 prs: - "111111" - """)); - - FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) + ); + + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -181,11 +206,15 @@ public ChangelogMultipleBundlesTests(ITestOutputHelper output) : base(output, target: 9.3.0 prs: - "222222" - """)); - - FileSystem.AddFile("docs/changelog/bundles/9.10.0.yaml", new MockFileData( - // language=yaml """ + ) + ); + + FileSystem.AddFile( + "docs/changelog/bundles/9.10.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.10.0 @@ -197,7 +226,9 @@ public ChangelogMultipleBundlesTests(ITestOutputHelper output) : base(output, target: 9.10.0 prs: - "333333" - """)); + """ + ) + ); } [Fact] @@ -230,17 +261,21 @@ public void RendersAllVersions() ///
public class ChangelogVersionFilterTests : DirectiveTest { - public ChangelogVersionFilterTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogVersionFilterTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :version: 9.3.0 ::: - """) + """ + ) { - FileSystem.AddFile("docs/changelog/bundles/9.2.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/9.2.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.2.0 @@ -252,11 +287,15 @@ public ChangelogVersionFilterTests(ITestOutputHelper output) : base(output, target: 9.2.0 prs: - "111111" - """)); - - FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) + ); + + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -268,7 +307,9 @@ public ChangelogVersionFilterTests(ITestOutputHelper output) : base(output, target: 9.3.0 prs: - "222222" - """)); + """ + ) + ); } [Fact] @@ -291,15 +332,20 @@ public void RendersOnlyMatchingVersion() /// public class ChangelogVersionFilterNoMatchTests : DirectiveTest { - public ChangelogVersionFilterNoMatchTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogVersionFilterNoMatchTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :version: 1.2.3 ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -311,7 +357,9 @@ public ChangelogVersionFilterNoMatchTests(ITestOutputHelper output) : base(outpu target: 9.3.0 prs: - "222222" - """)); + """ + ) + ); [Fact] public void LoadsNoBundles() => Block!.LoadedBundles.Should().BeEmpty(); @@ -323,14 +371,19 @@ public void EmitsWarningForUnmatchedVersion() => public class ChangelogCustomPathTests : DirectiveTest { - public ChangelogCustomPathTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogCustomPathTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} release-notes/bundles ::: - """) => FileSystem.AddFile("docs/release-notes/bundles/1.0.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/release-notes/bundles/1.0.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: my-product target: 1.0.0 @@ -342,13 +395,16 @@ public ChangelogCustomPathTests(ITestOutputHelper output) : base(output, target: 1.0.0 prs: - "1" - """)); + """ + ) + ); [Fact] public void FindsBundlesFolder() => Block!.Found.Should().BeTrue(); [Fact] - public void SetsCorrectBundlesFolderPath() => Block!.BundlesFolderPath.Should().EndWith("release-notes/bundles".Replace('/', Path.DirectorySeparatorChar)); + public void SetsCorrectBundlesFolderPath() => + Block!.BundlesFolderPath.Should().EndWith("release-notes/bundles".Replace('/', Path.DirectorySeparatorChar)); [Fact] public void RendersContent() @@ -363,13 +419,15 @@ public void RendersContent() /// assigned to the block and before any network access, so this test exercises the wiring without /// touching the CDN. /// -public class ChangelogCdnInvalidProductTests(ITestOutputHelper output) : DirectiveTest(output, +public class ChangelogCdnInvalidProductTests(ITestOutputHelper output) : DirectiveTest( + output, // language=markdown """ :::{changelog} :cdn: invalid$product ::: - """) + """ +) { [Fact] public void DoesNotCaptureInvalidCdnProduct() => Block!.CdnProduct.Should().BeNull(); @@ -391,21 +449,24 @@ public void EmitsErrorForInvalidProduct() /// instead of hitting the network. Regression guard: the HTML renderer previously gated on the /// (CDN-null) local bundles folder path and silently emitted an empty body. /// -public class ChangelogCdnRenderTests(ITestOutputHelper output) : DirectiveTest(output, +public class ChangelogCdnRenderTests(ITestOutputHelper output) : DirectiveTest( + output, // language=markdown """ :::{changelog} :cdn: cdn-render-test ::: - """) + """ +) { private const string Product = "cdn-render-test"; protected override IReleaseNotesResolver GetReleaseNotesResolver() => - ChangelogCdnTestResolver.For(Product, + ChangelogCdnTestResolver.For( + Product, ("9.4.0.yaml", - // language=yaml - """ + // language=yaml + """ products: - product: cdn-render-test target: 9.4.0 @@ -419,7 +480,8 @@ protected override IReleaseNotesResolver GetReleaseNotesResolver() => target: 9.4.0 prs: - "999" - """)); + """) + ); [Fact] public void FoundFromCdn() => Block!.Found.Should().BeTrue(); @@ -437,22 +499,25 @@ public void RendersCdnBundleBody() /// Verifies :cdn: combined with :version: renders only the matching prefetched bundle. /// Version filtering is applied to the injected resolver's bundles, so no network access occurs. /// -public class ChangelogCdnVersionFilterTests(ITestOutputHelper output) : DirectiveTest(output, +public class ChangelogCdnVersionFilterTests(ITestOutputHelper output) : DirectiveTest( + output, // language=markdown """ :::{changelog} :cdn: cdn-version-test :version: 9.4.0 ::: - """) + """ +) { private const string Product = "cdn-version-test"; protected override IReleaseNotesResolver GetReleaseNotesResolver() => - ChangelogCdnTestResolver.For(Product, + ChangelogCdnTestResolver.For( + Product, ("9.4.0.yaml", - // language=yaml - """ + // language=yaml + """ products: - product: cdn-version-test target: 9.4.0 @@ -468,8 +533,8 @@ protected override IReleaseNotesResolver GetReleaseNotesResolver() => - "999" """), ("9.3.0.yaml", - // language=yaml - """ + // language=yaml + """ products: - product: cdn-version-test target: 9.3.0 @@ -483,7 +548,8 @@ protected override IReleaseNotesResolver GetReleaseNotesResolver() => target: 9.3.0 prs: - "998" - """)); + """) + ); [Fact] public void CapturesVersionFilter() => Block!.VersionFilter.Should().Be("9.4.0"); @@ -502,13 +568,15 @@ public void RendersOnlyMatchingVersion() /// .git marker present the mock git checkout reports the repository as docs-builder, so /// the directive selects that product from the injected resolver. /// -public class ChangelogCdnInferredProductTests(ITestOutputHelper output) : DirectiveTest(output, +public class ChangelogCdnInferredProductTests(ITestOutputHelper output) : DirectiveTest( + output, // language=markdown """ :::{changelog} :cdn: ::: - """) + """ +) { private const string InferredProduct = "docs-builder"; @@ -518,10 +586,11 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) => fileSystem.AddDirectory(Path.Combine(Paths.WorkingDirectoryRoot.FullName, ".git")); protected override IReleaseNotesResolver GetReleaseNotesResolver() => - ChangelogCdnTestResolver.For(InferredProduct, + ChangelogCdnTestResolver.For( + InferredProduct, ("9.4.0.yaml", - // language=yaml - """ + // language=yaml + """ products: - product: docs-builder target: 9.4.0 @@ -535,7 +604,8 @@ protected override IReleaseNotesResolver GetReleaseNotesResolver() => target: 9.4.0 prs: - "999" - """)); + """) + ); [Fact] public void InfersProductFromRepository() => Block!.CdnProduct.Should().Be(InferredProduct); @@ -552,13 +622,15 @@ public void RendersInferredCdnBundleBody() /// A valueless :cdn: must fail with a clear error when the product cannot be inferred (no git /// information available), rather than silently rendering empty. /// -public class ChangelogCdnInferredProductUnavailableTests(ITestOutputHelper output) : DirectiveTest(output, +public class ChangelogCdnInferredProductUnavailableTests(ITestOutputHelper output) : DirectiveTest( + output, // language=markdown """ :::{changelog} :cdn: ::: - """) + """ +) { // Force Unavailable so InferCdnProductFromRepository() returns null — the "could not be inferred" path. protected override GitCheckoutInformation? GetGitCheckoutInformation() => GitCheckoutInformation.Unavailable; @@ -575,20 +647,23 @@ public void EmitsErrorWhenProductCannotBeInferred() /// A :cdn: product that is not declared under release_notes in docset.yml must fail with a /// clear error (the bundles were never prefetched), pointing the author at the declaration to add. /// -public class ChangelogCdnUndeclaredProductTests(ITestOutputHelper output) : DirectiveTest(output, +public class ChangelogCdnUndeclaredProductTests(ITestOutputHelper output) : DirectiveTest( + output, // language=markdown """ :::{changelog} :cdn: not-declared ::: - """) + """ +) { [Fact] public void EmitsErrorWhenProductIsNotDeclared() { Block!.Found.Should().BeFalse(); - Collector.Diagnostics.Should().Contain(d => - d.Message.Contains("not declared in docset.yml") && d.Message.Contains("release_notes")); + Collector.Diagnostics + .Should() + .Contain(d => d.Message.Contains("not declared in docset.yml") && d.Message.Contains("release_notes")); } } @@ -603,21 +678,23 @@ public static IReleaseNotesResolver For(string product, params (string FileName, var bundles = new BundleLoader(new MockFileSystem()).LoadBundlesFromContent(bundleContents, _ => { }); return new ReleaseNotesResolver(new FetchedReleaseNotes { - BundlesByProduct = new Dictionary>(StringComparer.Ordinal) - { - [product] = bundles - }.ToFrozenDictionary(StringComparer.Ordinal), + BundlesByProduct = + new Dictionary>(StringComparer.Ordinal) { [product] = bundles }.ToFrozenDictionary( + StringComparer.Ordinal + ), DeclaredProducts = new[] { product }.ToFrozenSet(StringComparer.Ordinal) }); } } -public class ChangelogNotFoundTests(ITestOutputHelper output) : DirectiveTest(output, +public class ChangelogNotFoundTests(ITestOutputHelper output) : DirectiveTest( + output, // language=markdown """ :::{changelog} missing-bundles ::: - """) + """ +) { [Fact] public void ReportsFolderNotFound() => Block!.Found.Should().BeFalse(); @@ -630,12 +707,14 @@ public void EmitsErrorForMissingFolder() } } -public class ChangelogDefaultPathMissingTests(ITestOutputHelper output) : DirectiveTest(output, +public class ChangelogDefaultPathMissingTests(ITestOutputHelper output) : DirectiveTest( + output, // language=markdown """ :::{changelog} ::: - """) + """ +) { [Fact] public void EmitsErrorForMissingDefaultFolder() @@ -652,15 +731,20 @@ public void EmitsErrorForMissingDefaultFolder() /// public class ChangelogWithBreakingChangesTests : DirectiveTest { - public ChangelogWithBreakingChangesTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogWithBreakingChangesTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: all ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -675,7 +759,9 @@ public ChangelogWithBreakingChangesTests(ITestOutputHelper output) : base(output action: Follow the migration guide. prs: - "222222" - """)); + """ + ) + ); [Fact] public void RendersBreakingChangesSection() @@ -700,15 +786,20 @@ public void RendersImpactAndAction() /// public class ChangelogWithDeprecationsTests : DirectiveTest { - public ChangelogWithDeprecationsTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogWithDeprecationsTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: all ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -723,7 +814,9 @@ public ChangelogWithDeprecationsTests(ITestOutputHelper output) : base(output, action: Use the new API instead. prs: - "333333" - """)); + """ + ) + ); [Fact] public void RendersDeprecationsSection() @@ -735,19 +828,26 @@ public void RendersDeprecationsSection() public class ChangelogEmptyBundleTests : DirectiveTest { - public ChangelogEmptyBundleTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogEmptyBundleTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 entries: [] - """)); + """ + ) + ); [Fact] public void OmitsEmptyVersionBlock() @@ -759,12 +859,14 @@ public void OmitsEmptyVersionBlock() public class ChangelogEmptyFolderTests : DirectiveTest { - public ChangelogEmptyFolderTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogEmptyFolderTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => + """ + ) => // Create the folder but don't add any YAML files FileSystem.AddDirectory("docs/changelog/bundles"); @@ -781,14 +883,19 @@ public void EmitsErrorForEmptyFolder() public class ChangelogAbsolutePathTests : DirectiveTest { - public ChangelogAbsolutePathTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogAbsolutePathTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} /release-notes/bundles ::: - """) => FileSystem.AddFile("docs/release-notes/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/release-notes/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -800,7 +907,9 @@ public ChangelogAbsolutePathTests(ITestOutputHelper output) : base(output, target: 9.3.0 prs: - "444444" - """)); + """ + ) + ); [Fact] public void FindsBundlesFolderWithAbsolutePath() => Block!.Found.Should().BeTrue(); @@ -815,15 +924,20 @@ public ChangelogAbsolutePathTests(ITestOutputHelper output) : base(output, /// public class ChangelogSectionOrderTests : DirectiveTest { - public ChangelogSectionOrderTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogSectionOrderTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: all ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -879,7 +993,9 @@ public ChangelogSectionOrderTests(ITestOutputHelper output) : base(output, target: 9.3.0 prs: - "666666" - """)); + """ + ) + ); [Fact] public void BreakingChangesAppearsFirst() @@ -925,14 +1041,19 @@ public void DeprecationsAppearsBeforeFeatures() /// public class ChangelogHeaderLevelsTests : DirectiveTest { - public ChangelogHeaderLevelsTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogHeaderLevelsTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -951,7 +1072,9 @@ public ChangelogHeaderLevelsTests(ITestOutputHelper output) : base(output, target: 9.3.0 prs: - "222222" - """)); + """ + ) + ); [Fact] public void VersionHeaderIsH2() @@ -999,15 +1122,20 @@ private static int CountOccurrences(string text, string pattern) /// public class ChangelogTitleDescriptionSpacingTests : DirectiveTest { - public ChangelogTitleDescriptionSpacingTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogTitleDescriptionSpacingTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :description-visibility: keep-descriptions ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -1018,19 +1146,18 @@ public ChangelogTitleDescriptionSpacingTests(ITestOutputHelper output) : base(ou - product: elasticsearch target: 9.3.0 description: This PR introduces the following settings. - """)); + """ + ) + ); [Fact] - public void RendersTitleText() => - Html.Should().Contain("Added missing banner-related Kibana settings to the settings allowlist"); + public void RendersTitleText() => Html.Should().Contain("Added missing banner-related Kibana settings to the settings allowlist"); [Fact] - public void RendersDescriptionText() => - Html.Should().Contain("This PR introduces the following settings"); + public void RendersDescriptionText() => Html.Should().Contain("This PR introduces the following settings"); [Fact] - public void DoesNotConcatenateTitleAndDescriptionWithoutSeparator() => - Html.Should().NotContain("allowlist.This PR introduces"); + public void DoesNotConcatenateTitleAndDescriptionWithoutSeparator() => Html.Should().NotContain("allowlist.This PR introduces"); } /// @@ -1038,15 +1165,20 @@ public void DoesNotConcatenateTitleAndDescriptionWithoutSeparator() => /// public class ChangelogReleaseDateTests : DirectiveTest { - public ChangelogReleaseDateTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogReleaseDateTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :release-dates: ::: - """) => FileSystem.AddFile("docs/changelog/bundles/1.34.0.yaml", new MockFileData( - // language=yaml - """ + """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/1.34.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: apm-agent-dotnet target: 1.34.0 @@ -1059,15 +1191,15 @@ public ChangelogReleaseDateTests(ITestOutputHelper output) : base(output, target: 1.34.0 prs: - "500" - """)); + """ + ) + ); [Fact] - public void RendersReleaseDate() => - Html.Should().Contain("Released: April 9, 2026"); + public void RendersReleaseDate() => Html.Should().Contain("Released: April 9, 2026"); [Fact] - public void RendersEntries() => - Html.Should().Contain("Add tracing improvements"); + public void RendersEntries() => Html.Should().Contain("Add tracing improvements"); } /// @@ -1075,14 +1207,19 @@ public void RendersEntries() => /// public class ChangelogNoReleaseDateTests : DirectiveTest { - public ChangelogNoReleaseDateTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogNoReleaseDateTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -1094,11 +1231,12 @@ public ChangelogNoReleaseDateTests(ITestOutputHelper output) : base(output, target: 9.3.0 prs: - "100" - """)); + """ + ) + ); [Fact] - public void DoesNotRenderReleaseDate() => - Html.Should().NotContain("Released:"); + public void DoesNotRenderReleaseDate() => Html.Should().NotContain("Released:"); } /// @@ -1106,15 +1244,20 @@ public void DoesNotRenderReleaseDate() => /// public class ChangelogReleaseDateWithDescriptionTests : DirectiveTest { - public ChangelogReleaseDateWithDescriptionTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogReleaseDateWithDescriptionTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :release-dates: ::: - """) => FileSystem.AddFile("docs/changelog/bundles/1.34.0.yaml", new MockFileData( - // language=yaml - """ + """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/1.34.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: apm-agent-dotnet target: 1.34.0 @@ -1129,17 +1272,16 @@ This release includes tracing improvements and bug fixes. target: 1.34.0 prs: - "500" - """)); + """ + ) + ); [Fact] - public void RendersReleaseDate() => - Html.Should().Contain("Released: April 9, 2026"); + public void RendersReleaseDate() => Html.Should().Contain("Released: April 9, 2026"); [Fact] - public void RendersDescription() => - Html.Should().Contain("This release includes tracing improvements and bug fixes."); + public void RendersDescription() => Html.Should().Contain("This release includes tracing improvements and bug fixes."); [Fact] - public void RendersEntries() => - Html.Should().Contain("Add tracing improvements"); + public void RendersEntries() => Html.Should().Contain("Add tracing improvements"); } diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogConfigTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogConfigTests.cs index 947b0a04f5..0f15b2a764 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogConfigTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogConfigTests.cs @@ -10,18 +10,22 @@ namespace Elastic.Markdown.Tests.Directives; public class ChangelogConfigLoadAutoDiscoverTests : DirectiveTest { - public ChangelogConfigLoadAutoDiscoverTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogConfigLoadAutoDiscoverTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: all ::: - """) + """ + ) { // Create bundles with entries of different types - FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -52,18 +56,24 @@ public ChangelogConfigLoadAutoDiscoverTests(ITestOutputHelper output) : base(out impact: Some users may be affected. prs: - "333333" - """)); + """ + ) + ); // Add changelog config with publish blockers - FileSystem.AddFile("docs/changelog.yml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog.yml", + new MockFileData( + // language=yaml + """ rules: publish: exclude_types: - deprecation - known-issue - """)); + """ + ) + ); } [Fact] @@ -90,17 +100,21 @@ public void RendersAllEntries_NoFiltering() public class ChangelogConfigLoadExplicitPathTests : DirectiveTest { - public ChangelogConfigLoadExplicitPathTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogConfigLoadExplicitPathTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :config: custom/path/my-changelog.yml ::: - """) + """ + ) { - FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -121,17 +135,23 @@ public ChangelogConfigLoadExplicitPathTests(ITestOutputHelper output) : base(out - Internal prs: - "222222" - """)); + """ + ) + ); // Add custom config at explicit path - FileSystem.AddFile("docs/custom/path/my-changelog.yml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/custom/path/my-changelog.yml", + new MockFileData( + // language=yaml + """ rules: publish: exclude_areas: - Internal - """)); + """ + ) + ); } [Fact] @@ -151,16 +171,20 @@ public void RendersAllEntries_NoFiltering() public class ChangelogConfigLoadFromDocsSubfolderTests : DirectiveTest { - public ChangelogConfigLoadFromDocsSubfolderTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogConfigLoadFromDocsSubfolderTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) + """ + ) { - FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -179,17 +203,23 @@ public ChangelogConfigLoadFromDocsSubfolderTests(ITestOutputHelper output) : bas target: 9.3.0 prs: - "222222" - """)); + """ + ) + ); // Add config in docs/docs/changelog.yml (docs subfolder) - FileSystem.AddFile("docs/docs/changelog.yml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/docs/changelog.yml", + new MockFileData( + // language=yaml + """ rules: publish: exclude_types: - other - """)); + """ + ) + ); } [Fact] @@ -206,14 +236,19 @@ public void RendersAllEntries_NoFiltering() public class ChangelogConfigNotFoundTests : DirectiveTest { - public ChangelogConfigNotFoundTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogConfigNotFoundTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -225,7 +260,9 @@ public ChangelogConfigNotFoundTests(ITestOutputHelper output) : base(output, target: 9.3.0 prs: - "111111" - """)); + """ + ) + ); [Fact] public void PublishBlockerIsNullWhenNoConfig() => Block!.PublishBlocker.Should().BeNull(); @@ -234,21 +271,25 @@ public ChangelogConfigNotFoundTests(ITestOutputHelper output) : base(output, public void RendersAllEntriesWhenNoConfig() => Html.Should().Contain("Regular feature"); [Fact] - public void NoErrorsEmittedForMissingConfig() => - Collector.Diagnostics.Should().NotContain(d => d.Message.Contains("changelog.yml")); + public void NoErrorsEmittedForMissingConfig() => Collector.Diagnostics.Should().NotContain(d => d.Message.Contains("changelog.yml")); } public class ChangelogConfigExplicitPathNotFoundTests : DirectiveTest { - public ChangelogConfigExplicitPathNotFoundTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogConfigExplicitPathNotFoundTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :config: nonexistent/config.yml ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -260,7 +301,9 @@ public ChangelogConfigExplicitPathNotFoundTests(ITestOutputHelper output) : base target: 9.3.0 prs: - "111111" - """)); + """ + ) + ); [Fact] public void PublishBlockerIsNullWhenExplicitConfigNotFound() => Block!.PublishBlocker.Should().BeNull(); @@ -275,17 +318,21 @@ public void EmitsWarningForMissingExplicitConfig() => public class ChangelogConfigPriorityTests : DirectiveTest { - public ChangelogConfigPriorityTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogConfigPriorityTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: all ::: - """) + """ + ) { - FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -314,26 +361,36 @@ public ChangelogConfigPriorityTests(ITestOutputHelper output) : base(output, target: 9.3.0 prs: - "333333" - """)); + """ + ) + ); // Add both config files - root should take priority - FileSystem.AddFile("docs/changelog.yml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog.yml", + new MockFileData( + // language=yaml + """ rules: publish: exclude_types: - deprecation - """)); - - FileSystem.AddFile("docs/docs/changelog.yml", new MockFileData( - // language=yaml """ + ) + ); + + FileSystem.AddFile( + "docs/docs/changelog.yml", + new MockFileData( + // language=yaml + """ rules: publish: exclude_types: - other - """)); + """ + ) + ); } [Fact] @@ -348,16 +405,20 @@ public void RendersAllEntries_NoPublishFiltering() public class ChangelogConfigEmptyBlockTests : DirectiveTest { - public ChangelogConfigEmptyBlockTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogConfigEmptyBlockTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) + """ + ) { - FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -369,18 +430,24 @@ public ChangelogConfigEmptyBlockTests(ITestOutputHelper output) : base(output, target: 9.3.0 prs: - "111111" - """)); + """ + ) + ); // Config file exists but has no block section - FileSystem.AddFile("docs/changelog.yml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog.yml", + new MockFileData( + // language=yaml + """ lifecycles: - preview - beta - ga - experimental - """)); + """ + ) + ); } [Fact] @@ -392,17 +459,21 @@ public ChangelogConfigEmptyBlockTests(ITestOutputHelper output) : base(output, public class ChangelogConfigMixedBlockersTests : DirectiveTest { - public ChangelogConfigMixedBlockersTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogConfigMixedBlockersTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: all ::: - """) + """ + ) { - FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -444,19 +515,25 @@ public ChangelogConfigMixedBlockersTests(ITestOutputHelper output) : base(output target: 9.3.0 prs: - "444444" - """)); + """ + ) + ); // Config with both type and area blockers - FileSystem.AddFile("docs/changelog.yml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog.yml", + new MockFileData( + // language=yaml + """ rules: publish: exclude_types: - deprecation exclude_areas: - Internal - """)); + """ + ) + ); } [Fact] @@ -473,18 +550,22 @@ public void RendersAllEntries_NoFiltering() } } -public class ChangelogProductFallbackSingleProductTests(ITestOutputHelper output) : DirectiveTest(output, +public class ChangelogProductFallbackSingleProductTests(ITestOutputHelper output) : DirectiveTest( + output, // language=markdown """ :::{changelog} ::: - """) + """ +) { protected override void AddToFileSystem(MockFileSystem fileSystem) { - fileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + fileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: kibana target: 9.3.0 @@ -514,12 +595,16 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) - Elastic Observability prs: - "333333" - """)); + """ + ) + ); // Config with product-specific blocker for kibana - fileSystem.AddFile("docs/changelog.yml", new MockFileData( - // language=yaml - """ + fileSystem.AddFile( + "docs/changelog.yml", + new MockFileData( + // language=yaml + """ rules: publish: exclude_areas: @@ -529,7 +614,9 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) exclude_areas: - Internal - Elastic Observability - """)); + """ + ) + ); } protected override IReadOnlyList? GetDocsetProducts() => ["kibana"]; @@ -547,18 +634,22 @@ public void RendersAllEntries_NoFiltering() } } -public class ChangelogProductFallbackMultipleProductsTests(ITestOutputHelper output) : DirectiveTest(output, +public class ChangelogProductFallbackMultipleProductsTests(ITestOutputHelper output) : DirectiveTest( + output, // language=markdown """ :::{changelog} ::: - """) + """ +) { protected override void AddToFileSystem(MockFileSystem fileSystem) { - fileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + fileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -579,12 +670,16 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) - Internal prs: - "222222" - """)); + """ + ) + ); // Config with product-specific blockers - fileSystem.AddFile("docs/changelog.yml", new MockFileData( - // language=yaml - """ + fileSystem.AddFile( + "docs/changelog.yml", + new MockFileData( + // language=yaml + """ rules: publish: exclude_areas: @@ -593,7 +688,9 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) elasticsearch: exclude_areas: - Internal - """)); + """ + ) + ); } // Docset with multiple products - should fall back to global blocker @@ -611,19 +708,23 @@ public void RendersAllEntries_NoFiltering() } } -public class ChangelogProductExplicitOptionOverridesDocsetTests(ITestOutputHelper output) : DirectiveTest(output, +public class ChangelogProductExplicitOptionOverridesDocsetTests(ITestOutputHelper output) : DirectiveTest( + output, // language=markdown """ :::{changelog} :product: elasticsearch ::: - """) + """ +) { protected override void AddToFileSystem(MockFileSystem fileSystem) { - fileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + fileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -653,12 +754,16 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) - Kibana Internal prs: - "333333" - """)); + """ + ) + ); // Config with different blockers for different products - fileSystem.AddFile("docs/changelog.yml", new MockFileData( - // language=yaml - """ + fileSystem.AddFile( + "docs/changelog.yml", + new MockFileData( + // language=yaml + """ rules: publish: products: @@ -668,15 +773,16 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) kibana: exclude_areas: - Kibana Internal - """)); + """ + ) + ); } // Docset has kibana as single product, but directive explicitly requests elasticsearch protected override IReadOnlyList? GetDocsetProducts() => ["kibana"]; [Fact] - public void ExplicitProductOptionIsSet() => - Block!.ProductId.Should().Be("elasticsearch"); + public void ExplicitProductOptionIsSet() => Block!.ProductId.Should().Be("elasticsearch"); [Fact] public void PublishBlockerIsNull() => Block!.PublishBlocker.Should().BeNull(); diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogDescriptionVisibilityTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogDescriptionVisibilityTests.cs index d387ac6e66..637f624d0d 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogDescriptionVisibilityTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogDescriptionVisibilityTests.cs @@ -19,7 +19,8 @@ public void HideDescriptions_AlwaysReturnsTrue() var result = ChangelogInlineRenderer.ShouldHideEntryDescriptionsForRepo( "kibana", privateRepos, - ChangelogDescriptionVisibility.HideDescriptions); + ChangelogDescriptionVisibility.HideDescriptions + ); result.Should().BeTrue(); } @@ -30,7 +31,8 @@ public void KeepDescriptions_AlwaysReturnsFalse() var result = ChangelogInlineRenderer.ShouldHideEntryDescriptionsForRepo( "kibana", [], - ChangelogDescriptionVisibility.KeepDescriptions); + ChangelogDescriptionVisibility.KeepDescriptions + ); result.Should().BeFalse(); } @@ -42,7 +44,8 @@ public void KeepHighlightDescriptions_AlwaysReturnsTrue() var result = ChangelogInlineRenderer.ShouldHideEntryDescriptionsForRepo( "kibana", [], - ChangelogDescriptionVisibility.KeepHighlightDescriptions); + ChangelogDescriptionVisibility.KeepHighlightDescriptions + ); result.Should().BeTrue(); } @@ -50,10 +53,7 @@ public void KeepHighlightDescriptions_AlwaysReturnsTrue() [Fact] public void Auto_WithEmptyPrivateRepos_HidesBodies() { - var result = ChangelogInlineRenderer.ShouldHideEntryDescriptionsForRepo( - "kibana", - [], - ChangelogDescriptionVisibility.Auto); + var result = ChangelogInlineRenderer.ShouldHideEntryDescriptionsForRepo("kibana", [], ChangelogDescriptionVisibility.Auto); result.Should().BeTrue(); } @@ -66,7 +66,8 @@ public void Auto_WithPublicRepoOnly_HidesBodies() var result = ChangelogInlineRenderer.ShouldHideEntryDescriptionsForRepo( "kibana", privateRepos, - ChangelogDescriptionVisibility.Auto); + ChangelogDescriptionVisibility.Auto + ); result.Should().BeTrue(); } @@ -79,7 +80,8 @@ public void Auto_WithPrivateRepo_ShowsBodies() var result = ChangelogInlineRenderer.ShouldHideEntryDescriptionsForRepo( "kibana", privateRepos, - ChangelogDescriptionVisibility.Auto); + ChangelogDescriptionVisibility.Auto + ); result.Should().BeFalse(); } @@ -92,7 +94,8 @@ public void Auto_WithMergedBundle_OnePrivateConstituent_ShowsBodies() var result = ChangelogInlineRenderer.ShouldHideEntryDescriptionsForRepo( "elasticsearch+kibana", privateRepos, - ChangelogDescriptionVisibility.Auto); + ChangelogDescriptionVisibility.Auto + ); result.Should().BeFalse(); } @@ -105,7 +108,8 @@ public void Auto_WithMergedBundle_AllPublicConstituents_HidesBodies() var result = ChangelogInlineRenderer.ShouldHideEntryDescriptionsForRepo( "elasticsearch+kibana", privateRepos, - ChangelogDescriptionVisibility.Auto); + ChangelogDescriptionVisibility.Auto + ); result.Should().BeTrue(); } @@ -114,15 +118,19 @@ public void Auto_WithMergedBundle_AllPublicConstituents_HidesBodies() /// /// Omitting :description-visibility: defaults to . /// -public class ChangelogDescriptionVisibilityDefaultTests(ITestOutputHelper output) : DirectiveTest(output, +public class ChangelogDescriptionVisibilityDefaultTests(ITestOutputHelper output) : DirectiveTest( + output, """ :::{changelog} ::: - """) + """ +) { protected override void AddToFileSystem(MockFileSystem fileSystem) => - fileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - """ + fileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + """ products: - product: elasticsearch target: 9.3.0 @@ -133,31 +141,34 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) => - product: elasticsearch target: 9.3.0 description: BODY_DEFAULT_AUTO_VISIBILITY - """)); + """ + ) + ); [Fact] - public void PropertyDefaultsToAuto() => - Block!.DescriptionVisibility.Should().Be(ChangelogDescriptionVisibility.Auto); + public void PropertyDefaultsToAuto() => Block!.DescriptionVisibility.Should().Be(ChangelogDescriptionVisibility.Auto); /// Public bundle with no assembler private repos ⇒ auto hides record bodies. [Fact] - public void HtmlOmitsBodyTextForPublicBundle() => - Html.Should().NotContain("BODY_DEFAULT_AUTO_VISIBILITY"); + public void HtmlOmitsBodyTextForPublicBundle() => Html.Should().NotContain("BODY_DEFAULT_AUTO_VISIBILITY"); [Fact] - public void HtmlStillRendersTitles() => - Html.Should().Contain("Feature delta"); + public void HtmlStillRendersTitles() => Html.Should().Contain("Feature delta"); } -public class ChangelogDescriptionVisibilityAutoShowsForPrivateRepoTests(ITestOutputHelper output) : DirectiveTest(output, +public class ChangelogDescriptionVisibilityAutoShowsForPrivateRepoTests(ITestOutputHelper output) : DirectiveTest( + output, """ :::{changelog} ::: - """) + """ +) { protected override void AddToFileSystem(MockFileSystem fileSystem) => - fileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - """ + fileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + """ products: - product: elasticsearch target: 9.3.0 @@ -168,7 +179,9 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) => - product: elasticsearch target: 9.3.0 description: BODY_PRIVATE_VISIBILITY_TEST - """)); + """ + ) + ); public override async ValueTask InitializeAsync() { @@ -191,16 +204,20 @@ public void MarkdownRendersTitle() } } -public class ChangelogDescriptionVisibilityKeepExplicitTests(ITestOutputHelper output) : DirectiveTest(output, +public class ChangelogDescriptionVisibilityKeepExplicitTests(ITestOutputHelper output) : DirectiveTest( + output, """ :::{changelog} :description-visibility: keep-descriptions ::: - """) + """ +) { protected override void AddToFileSystem(MockFileSystem fileSystem) => - fileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - """ + fileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + """ products: - product: elasticsearch target: 9.3.0 @@ -211,23 +228,28 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) => - product: elasticsearch target: 9.3.0 description: BODY_KEEP_VISIBILITY - """)); + """ + ) + ); [Fact] - public void KeepsBodyOnFullyPublicRepos() => - Html.Should().Contain("BODY_KEEP_VISIBILITY"); + public void KeepsBodyOnFullyPublicRepos() => Html.Should().Contain("BODY_KEEP_VISIBILITY"); } -public class ChangelogDescriptionVisibilityHideExplicitTests(ITestOutputHelper output) : DirectiveTest(output, +public class ChangelogDescriptionVisibilityHideExplicitTests(ITestOutputHelper output) : DirectiveTest( + output, """ :::{changelog} :description-visibility: hide-descriptions ::: - """) + """ +) { protected override void AddToFileSystem(MockFileSystem fileSystem) => - fileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - """ + fileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + """ products: - product: elasticsearch target: 9.3.0 @@ -238,11 +260,12 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) => - product: elasticsearch target: 9.3.0 description: BODY_HIDE_VISIBILITY - """)); + """ + ) + ); [Fact] - public void OmitBody() => - Html.Should().NotContain("BODY_HIDE_VISIBILITY"); + public void OmitBody() => Html.Should().NotContain("BODY_HIDE_VISIBILITY"); [Fact] public void MarkdownRendersTitlesWithoutBodies() @@ -253,16 +276,20 @@ public void MarkdownRendersTitlesWithoutBodies() } } -public class ChangelogDescriptionVisibilityInvalidTests(ITestOutputHelper output) : DirectiveTest(output, +public class ChangelogDescriptionVisibilityInvalidTests(ITestOutputHelper output) : DirectiveTest( + output, """ :::{changelog} :description-visibility: nonsense-value ::: - """) + """ +) { protected override void AddToFileSystem(MockFileSystem fileSystem) => - fileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - """ + fileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + """ products: - product: elasticsearch target: 9.3.0 @@ -273,17 +300,16 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) => - product: elasticsearch target: 9.3.0 description: BODY_INVALID_VISIBILITY - """)); + """ + ) + ); [Fact] - public void FallsBackToAuto() => - Block!.DescriptionVisibility.Should().Be(ChangelogDescriptionVisibility.Auto); + public void FallsBackToAuto() => Block!.DescriptionVisibility.Should().Be(ChangelogDescriptionVisibility.Auto); [Fact] - public void EmitsWarning() => - Collector.Warnings.Should().BeGreaterThan(0); + public void EmitsWarning() => Collector.Warnings.Should().BeGreaterThan(0); [Fact] - public void AutoTreatsFullyPublic_AsHideBody() => - Html.Should().NotContain("BODY_INVALID_VISIBILITY"); + public void AutoTreatsFullyPublic_AsHideBody() => Html.Should().NotContain("BODY_INVALID_VISIBILITY"); } diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogDropdownsTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogDropdownsTests.cs index 822bf8665a..e0cb6fe228 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogDropdownsTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogDropdownsTests.cs @@ -16,16 +16,21 @@ namespace Elastic.Markdown.Tests.Directives; /// public class ChangelogDropdownsDefaultTests : DirectiveTest { - public ChangelogDropdownsDefaultTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogDropdownsDefaultTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: breaking-change :description-visibility: keep-descriptions ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -50,13 +55,12 @@ public ChangelogDropdownsDefaultTests(ITestOutputHelper output) : base(output, action: Remove references to the deprecated parameter. prs: - "444444" - """)); + """ + ) + ); [Fact] - public void DefaultBehaviorDoesNotParseDropdownsOption() - { - Block!.DropdownsEnabled.Should().BeFalse(); - } + public void DefaultBehaviorDoesNotParseDropdownsOption() => Block!.DropdownsEnabled.Should().BeFalse(); [Fact] public void DefaultBehaviorRendersFlattened() @@ -94,17 +98,22 @@ public void DefaultBehaviorIncludesDescriptions() /// public class ChangelogDropdownsEnabledTests : DirectiveTest { - public ChangelogDropdownsEnabledTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogDropdownsEnabledTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: breaking-change :dropdowns: :description-visibility: keep-descriptions ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -119,13 +128,12 @@ public ChangelogDropdownsEnabledTests(ITestOutputHelper output) : base(output, action: Update your code to use the new API endpoints. prs: - "333333" - """)); + """ + ) + ); [Fact] - public void ExplicitDropdownsParsesCorrectly() - { - Block!.DropdownsEnabled.Should().BeTrue(); - } + public void ExplicitDropdownsParsesCorrectly() => Block!.DropdownsEnabled.Should().BeTrue(); [Fact] public void ExplicitDropdownsRendersDropdownFormat() @@ -140,10 +148,7 @@ public void ExplicitDropdownsRendersDropdownFormat() } [Fact] - public void ExplicitDropdownsIncludesDescriptionInDropdown() - { - Html.Should().Contain("API has been changed to improve performance."); - } + public void ExplicitDropdownsIncludesDescriptionInDropdown() => Html.Should().Contain("API has been changed to improve performance."); [Fact] public void ExplicitDropdownsIncludesImpactAndActionInDropdown() @@ -158,16 +163,21 @@ public void ExplicitDropdownsIncludesImpactAndActionInDropdown() /// public class ChangelogDropdownsWithHiddenDescriptionsTests : DirectiveTest { - public ChangelogDropdownsWithHiddenDescriptionsTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogDropdownsWithHiddenDescriptionsTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: breaking-change :description-visibility: hide-descriptions ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -182,7 +192,9 @@ public ChangelogDropdownsWithHiddenDescriptionsTests(ITestOutputHelper output) : action: Update your code to use the new API endpoints. prs: - "333333" - """)); + """ + ) + ); [Fact] public void FlattendRenderingHidesDescriptionsButKeepsImpactAction() @@ -205,17 +217,22 @@ public void FlattendRenderingHidesDescriptionsButKeepsImpactAction() /// public class ChangelogDropdownsEnabledWithHiddenDescriptionsTests : DirectiveTest { - public ChangelogDropdownsEnabledWithHiddenDescriptionsTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogDropdownsEnabledWithHiddenDescriptionsTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: breaking-change :dropdowns: :description-visibility: hide-descriptions ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -230,7 +247,9 @@ public ChangelogDropdownsEnabledWithHiddenDescriptionsTests(ITestOutputHelper ou action: Update your code to use the new API endpoints. prs: - "333333" - """)); + """ + ) + ); [Fact] public void DropdownRenderingHidesDescriptionsButKeepsImpactAction() @@ -254,15 +273,20 @@ public void DropdownRenderingHidesDescriptionsButKeepsImpactAction() /// public class ChangelogDropdownsWithDifferentTypesTests : DirectiveTest { - public ChangelogDropdownsWithDifferentTypesTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogDropdownsWithDifferentTypesTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: all ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -305,13 +329,15 @@ public ChangelogDropdownsWithDifferentTypesTests(ITestOutputHelper output) : bas action: Use new API. prs: - "444444" - """)); + """ + ) + ); [Fact] public void DefaultRendersMixedTypesCorrectly() { // Regular types should render as bulleted lists (unchanged behavior) - Html.Should().Contain("Feature addition."); // Regular feature type (in
  • tags) + Html.Should().Contain("Feature addition."); // Regular feature type (in
  • tags) // Separated types should render as flattened lists (new behavior) - no bold titles Html.Should().Contain("Breaking API change."); @@ -328,16 +354,21 @@ public void DefaultRendersMixedTypesCorrectly() /// public class ChangelogDropdownsExplicitWithDifferentTypesTests : DirectiveTest { - public ChangelogDropdownsExplicitWithDifferentTypesTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogDropdownsExplicitWithDifferentTypesTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: all :dropdowns: ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -370,13 +401,15 @@ public ChangelogDropdownsExplicitWithDifferentTypesTests(ITestOutputHelper outpu action: Use workaround. prs: - "333333" - """)); + """ + ) + ); [Fact] public void ExplicitDropdownsRendersMixedTypesCorrectly() { // Regular types should still render as bulleted lists (unchanged behavior) - Html.Should().Contain("Feature addition."); // Regular feature type (in
  • tags) + Html.Should().Contain("Feature addition."); // Regular feature type (in
  • tags) // Separated types should render as dropdowns (explicit :dropdowns:) Html.Should().Contain("
    "); @@ -394,17 +427,22 @@ public void ExplicitDropdownsRendersMixedTypesCorrectly() /// public class ChangelogDropdownsPlainTextTitleTests : DirectiveTest { - public ChangelogDropdownsPlainTextTitleTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogDropdownsPlainTextTitleTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: known-issue :dropdowns: :description-visibility: keep-descriptions ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -419,7 +457,9 @@ public ChangelogDropdownsPlainTextTitleTests(ITestOutputHelper output) : base(ou action: Update the template. prs: - "242365" - """)); + """ + ) + ); [Fact] public void ChangelogDropdownTitleStripsBackticksInHtml() diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogFlattenedLinksTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogFlattenedLinksTests.cs index c11ec63bad..b2feb48ac4 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogFlattenedLinksTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogFlattenedLinksTests.cs @@ -14,15 +14,20 @@ namespace Elastic.Markdown.Tests.Directives; /// public class ChangelogFlattenedLinksTests : DirectiveTest { - public ChangelogFlattenedLinksTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogFlattenedLinksTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: deprecation ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -37,7 +42,9 @@ public ChangelogFlattenedLinksTests(ITestOutputHelper output) : base(output, - "202446" issues: - "199001" - """)); + """ + ) + ); [Fact] public void FlattenedDeprecationRendersMultipleLinksWithoutOuterBrackets() diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogHideLinksTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogHideLinksTests.cs index f00b761172..d2d23d8d13 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogHideLinksTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogHideLinksTests.cs @@ -93,14 +93,19 @@ public void HandlesWhitespace_InMergedRepoNames() /// public class ChangelogLinksDefaultBehaviorTests : DirectiveTest { - public ChangelogLinksDefaultBehaviorTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogLinksDefaultBehaviorTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -115,7 +120,9 @@ public ChangelogLinksDefaultBehaviorTests(ITestOutputHelper output) : base(outpu issues: - "78901" - "78902" - """)); + """ + ) + ); [Fact] public void PrivateRepositoriesPropertyIsAccessible() => @@ -146,14 +153,19 @@ public void RendersIssueLinksForPublicRepo() /// public class ChangelogLinksHiddenForPrivateRepoTests : DirectiveTest { - public ChangelogLinksHiddenForPrivateRepoTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogLinksHiddenForPrivateRepoTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -168,7 +180,9 @@ public ChangelogLinksHiddenForPrivateRepoTests(ITestOutputHelper output) : base( issues: - "78901" - "78902" - """)); + """ + ) + ); public override async ValueTask InitializeAsync() { @@ -178,8 +192,7 @@ public override async ValueTask InitializeAsync() } [Fact] - public void PrivateRepositoriesContainsConfiguredRepo() => - Block!.PrivateRepositories.Should().Contain("elasticsearch"); + public void PrivateRepositoriesContainsConfiguredRepo() => Block!.PrivateRepositories.Should().Contain("elasticsearch"); [Fact] public void HidesPrLinksForPrivateRepo() @@ -212,17 +225,22 @@ public void HidesIssueLinksForPrivateRepo() /// public class ChangelogLinksHiddenInDetailedEntriesTests : DirectiveTest { - public ChangelogLinksHiddenInDetailedEntriesTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogLinksHiddenInDetailedEntriesTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: all :dropdowns: :description-visibility: keep-descriptions ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -249,7 +267,9 @@ public ChangelogLinksHiddenInDetailedEntriesTests(ITestOutputHelper output) : ba action: Use new API. prs: - "555444" - """)); + """ + ) + ); public override async ValueTask InitializeAsync() { @@ -298,14 +318,19 @@ public void RendersImpactAndActionSections() /// public class ChangelogLinksShownForPublicRepoTests : DirectiveTest { - public ChangelogLinksShownForPublicRepoTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogLinksShownForPublicRepoTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -317,7 +342,9 @@ public ChangelogLinksShownForPublicRepoTests(ITestOutputHelper output) : base(ou target: 9.3.0 prs: - "111111" - """)); + """ + ) + ); public override async ValueTask InitializeAsync() { @@ -344,17 +371,21 @@ public void ShowsPrLinksForPublicRepo() /// public class ChangelogLinksWithMergedBundlesTests : DirectiveTest { - public ChangelogLinksWithMergedBundlesTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogLinksWithMergedBundlesTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) + """ + ) { // Add bundles from two repos with the same target version (will be merged) - FileSystem.AddFile("docs/changelog/bundles/elasticsearch-2025-08-05.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/elasticsearch-2025-08-05.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 2025-08-05 @@ -366,11 +397,15 @@ public ChangelogLinksWithMergedBundlesTests(ITestOutputHelper output) : base(out target: 2025-08-05 prs: - "111111" - """)); - - FileSystem.AddFile("docs/changelog/bundles/kibana-2025-08-05.yaml", new MockFileData( - // language=yaml """ + ) + ); + + FileSystem.AddFile( + "docs/changelog/bundles/kibana-2025-08-05.yaml", + new MockFileData( + // language=yaml + """ products: - product: kibana target: 2025-08-05 @@ -382,7 +417,9 @@ public ChangelogLinksWithMergedBundlesTests(ITestOutputHelper output) : base(out target: 2025-08-05 prs: - "222222" - """)); + """ + ) + ); } public override async ValueTask InitializeAsync() @@ -429,17 +466,21 @@ public void HidesLinksWhenAnyMergedRepoIsPrivate() /// public class ChangelogLinksWithMergedPublicReposTests : DirectiveTest { - public ChangelogLinksWithMergedPublicReposTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogLinksWithMergedPublicReposTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) + """ + ) { // Add bundles from two public repos with the same target version - FileSystem.AddFile("docs/changelog/bundles/elasticsearch-2025-08-05.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/elasticsearch-2025-08-05.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 2025-08-05 @@ -451,11 +492,15 @@ public ChangelogLinksWithMergedPublicReposTests(ITestOutputHelper output) : base target: 2025-08-05 prs: - "111111" - """)); - - FileSystem.AddFile("docs/changelog/bundles/kibana-2025-08-05.yaml", new MockFileData( - // language=yaml """ + ) + ); + + FileSystem.AddFile( + "docs/changelog/bundles/kibana-2025-08-05.yaml", + new MockFileData( + // language=yaml + """ products: - product: kibana target: 2025-08-05 @@ -467,7 +512,9 @@ public ChangelogLinksWithMergedPublicReposTests(ITestOutputHelper output) : base target: 2025-08-05 prs: - "222222" - """)); + """ + ) + ); } public override async ValueTask InitializeAsync() @@ -497,15 +544,20 @@ public void ShowsLinksWhenAllMergedReposArePublic() /// public class ChangelogLinkVisibilityKeepLinksTests : DirectiveTest { - public ChangelogLinkVisibilityKeepLinksTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogLinkVisibilityKeepLinksTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :link-visibility: keep-links ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -517,7 +569,9 @@ public ChangelogLinkVisibilityKeepLinksTests(ITestOutputHelper output) : base(ou target: 9.3.0 prs: - "123456" - """)); + """ + ) + ); public override async ValueTask InitializeAsync() { @@ -526,8 +580,7 @@ public override async ValueTask InitializeAsync() } [Fact] - public void LinkVisibilityIsParsedAsKeepLinks() => - Block!.LinkVisibility.Should().Be(ChangelogLinkVisibility.KeepLinks); + public void LinkVisibilityIsParsedAsKeepLinks() => Block!.LinkVisibility.Should().Be(ChangelogLinkVisibility.KeepLinks); [Fact] public void ShowsLinksEvenWhenRepoIsPrivate() @@ -545,15 +598,20 @@ public void ShowsLinksEvenWhenRepoIsPrivate() /// public class ChangelogLinkVisibilityHideLinksTests : DirectiveTest { - public ChangelogLinkVisibilityHideLinksTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogLinkVisibilityHideLinksTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :link-visibility: hide-links ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -565,11 +623,12 @@ public ChangelogLinkVisibilityHideLinksTests(ITestOutputHelper output) : base(ou target: 9.3.0 prs: - "123456" - """)); + """ + ) + ); [Fact] - public void LinkVisibilityIsParsedAsHideLinks() => - Block!.LinkVisibility.Should().Be(ChangelogLinkVisibility.HideLinks); + public void LinkVisibilityIsParsedAsHideLinks() => Block!.LinkVisibility.Should().Be(ChangelogLinkVisibility.HideLinks); [Fact] public void HidesLinksEvenWhenRepoIsPublic() @@ -586,15 +645,20 @@ public void HidesLinksEvenWhenRepoIsPublic() /// public class ChangelogLinkVisibilityAutoTests : DirectiveTest { - public ChangelogLinkVisibilityAutoTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogLinkVisibilityAutoTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :link-visibility: auto ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -606,11 +670,12 @@ public ChangelogLinkVisibilityAutoTests(ITestOutputHelper output) : base(output, target: 9.3.0 prs: - "123456" - """)); + """ + ) + ); [Fact] - public void LinkVisibilityIsParsedAsAuto() => - Block!.LinkVisibility.Should().Be(ChangelogLinkVisibility.Auto); + public void LinkVisibilityIsParsedAsAuto() => Block!.LinkVisibility.Should().Be(ChangelogLinkVisibility.Auto); [Fact] public void ShowsLinksWhenRepoIsPublic() @@ -627,14 +692,19 @@ public void ShowsLinksWhenRepoIsPublic() /// public class ChangelogLinkVisibilityDefaultTests : DirectiveTest { - public ChangelogLinkVisibilityDefaultTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogLinkVisibilityDefaultTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -646,11 +716,12 @@ public ChangelogLinkVisibilityDefaultTests(ITestOutputHelper output) : base(outp target: 9.3.0 prs: - "123456" - """)); + """ + ) + ); [Fact] - public void LinkVisibilityDefaultsToAuto() => - Block!.LinkVisibility.Should().Be(ChangelogLinkVisibility.Auto); + public void LinkVisibilityDefaultsToAuto() => Block!.LinkVisibility.Should().Be(ChangelogLinkVisibility.Auto); } /// @@ -658,15 +729,20 @@ public void LinkVisibilityDefaultsToAuto() => /// public class ChangelogLinkVisibilityInvalidTests : DirectiveTest { - public ChangelogLinkVisibilityInvalidTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogLinkVisibilityInvalidTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :link-visibility: banana ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -678,37 +754,40 @@ public ChangelogLinkVisibilityInvalidTests(ITestOutputHelper output) : base(outp target: 9.3.0 prs: - "123456" - """)); + """ + ) + ); [Fact] - public void LinkVisibilityFallsBackToAuto() => - Block!.LinkVisibility.Should().Be(ChangelogLinkVisibility.Auto); + public void LinkVisibilityFallsBackToAuto() => Block!.LinkVisibility.Should().Be(ChangelogLinkVisibility.Auto); [Fact] - public void EmitsWarning() => - Collector.Warnings.Should().BeGreaterThan(0); + public void EmitsWarning() => Collector.Warnings.Should().BeGreaterThan(0); } /// /// CDN-sourced bundles are scrubbed for public delivery; :link-visibility: auto keeps links even when /// assembler.yml marks source repos private (including merged bundles with a private constituent). /// -public class ChangelogCdnLinkVisibilityAutoTests(ITestOutputHelper output) : DirectiveTest(output, +public class ChangelogCdnLinkVisibilityAutoTests(ITestOutputHelper output) : DirectiveTest( + output, // language=markdown """ :::{changelog} :cdn: cloud-serverless :link-visibility: auto ::: - """) + """ +) { private const string Product = "cloud-serverless"; protected override IReleaseNotesResolver GetReleaseNotesResolver() => - ChangelogCdnTestResolver.For(Product, + ChangelogCdnTestResolver.For( + Product, ("cloud-2026-07-07.yaml", - // language=yaml - """ + // language=yaml + """ products: - product: cloud-serverless target: 2026-07-07 @@ -724,8 +803,8 @@ protected override IReleaseNotesResolver GetReleaseNotesResolver() => - https://github.com/elastic/roadmap/issues/39 """), ("kibana-2026-07-07.yaml", - // language=yaml - """ + // language=yaml + """ products: - product: cloud-serverless target: 2026-07-07 @@ -739,7 +818,8 @@ protected override IReleaseNotesResolver GetReleaseNotesResolver() => target: 2026-07-07 prs: - https://github.com/elastic/kibana/pull/275693 - """)); + """) + ); public override async ValueTask InitializeAsync() { diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogHighlightsOptionTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogHighlightsOptionTests.cs index 5d5cf9765d..d65b981374 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogHighlightsOptionTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogHighlightsOptionTests.cs @@ -60,20 +60,20 @@ static file class ChangelogHighlightsFixtures /// Default (omitted) :highlights: — inline only, no Highlights section. public class ChangelogHighlightsOptionDefaultOffTests : DirectiveTest { - public ChangelogHighlightsOptionDefaultOffTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogHighlightsOptionDefaultOffTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData(ChangelogHighlightsFixtures.BundleYaml)); + """ + ) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData(ChangelogHighlightsFixtures.BundleYaml)); [Fact] - public void HighlightsDisabledByDefault() => - Block!.HighlightsEnabled.Should().BeFalse(); + public void HighlightsDisabledByDefault() => Block!.HighlightsEnabled.Should().BeFalse(); [Fact] - public void OmitsHighlightsSection() => - Html.Should().NotContain("Highlights"); + public void OmitsHighlightsSection() => Html.Should().NotContain("Highlights"); [Fact] public void StillRendersHighlightedEntryUnderTypeSection() @@ -86,8 +86,7 @@ public void StillRendersHighlightedEntryUnderTypeSection() } [Fact] - public void ExcludesSeparatedTypesByDefault() => - Html.Should().NotContain("Breaking changes"); + public void ExcludesSeparatedTypesByDefault() => Html.Should().NotContain("Breaking changes"); [Fact] public void TocOmitsHighlights() @@ -100,21 +99,21 @@ public void TocOmitsHighlights() /// :highlights: with default type filter — Highlights section plus type sections, no separated types. public class ChangelogHighlightsOptionEnabledTests : DirectiveTest { - public ChangelogHighlightsOptionEnabledTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogHighlightsOptionEnabledTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :highlights: ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData(ChangelogHighlightsFixtures.BundleYaml)); + """ + ) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData(ChangelogHighlightsFixtures.BundleYaml)); [Fact] - public void HighlightsEnabledWhenFlagPresent() => - Block!.HighlightsEnabled.Should().BeTrue(); + public void HighlightsEnabledWhenFlagPresent() => Block!.HighlightsEnabled.Should().BeTrue(); [Fact] - public void RendersHighlightsSection() => - Html.Should().Contain("Highlights"); + public void RendersHighlightsSection() => Html.Should().Contain("Highlights"); [Fact] public void DuplicatesHighlightedEntryInTypeSection() @@ -125,8 +124,7 @@ public void DuplicatesHighlightedEntryInTypeSection() } [Fact] - public void ExcludesSeparatedTypesWithoutTypeAll() => - Html.Should().NotContain("Breaking changes"); + public void ExcludesSeparatedTypesWithoutTypeAll() => Html.Should().NotContain("Breaking changes"); [Fact] public void TocIncludesHighlights() @@ -137,21 +135,22 @@ public void TocIncludesHighlights() } [Fact] - public void GeneratedAnchorsIncludeHighlights() => - Block!.GeneratedAnchors.Should().Contain("elasticsearch-9.3.0-highlights"); + public void GeneratedAnchorsIncludeHighlights() => Block!.GeneratedAnchors.Should().Contain("elasticsearch-9.3.0-highlights"); } /// :highlights: + :type: all — Highlights section and separated types. public class ChangelogHighlightsOptionWithTypeAllTests : DirectiveTest { - public ChangelogHighlightsOptionWithTypeAllTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogHighlightsOptionWithTypeAllTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: all :highlights: ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData(ChangelogHighlightsFixtures.BundleYaml)); + """ + ) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData(ChangelogHighlightsFixtures.BundleYaml)); [Fact] public void RendersHighlightsAndSeparatedTypes() @@ -165,13 +164,15 @@ public void RendersHighlightsAndSeparatedTypes() /// :type: all without :highlights: — no Highlights section (breaking change from prior All behavior). public class ChangelogHighlightsOptionTypeAllWithoutFlagTests : DirectiveTest { - public ChangelogHighlightsOptionTypeAllWithoutFlagTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogHighlightsOptionTypeAllWithoutFlagTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: all ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData(ChangelogHighlightsFixtures.BundleYaml)); + """ + ) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData(ChangelogHighlightsFixtures.BundleYaml)); [Fact] public void TypeAllAloneDoesNotEmitHighlightsSection() @@ -186,29 +187,31 @@ public void TypeAllAloneDoesNotEmitHighlightsSection() /// Legacy :type: highlight warns and falls back to default. public class ChangelogHighlightsLegacyTypeHighlightTests : DirectiveTest { - public ChangelogHighlightsLegacyTypeHighlightTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogHighlightsLegacyTypeHighlightTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: highlight ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData(ChangelogHighlightsFixtures.BundleYaml)); + """ + ) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData(ChangelogHighlightsFixtures.BundleYaml)); [Fact] - public void FallsBackToDefaultTypeFilter() => - Block!.TypeFilter.Should().Be(ChangelogTypeFilter.Default); + public void FallsBackToDefaultTypeFilter() => Block!.TypeFilter.Should().Be(ChangelogTypeFilter.Default); [Fact] - public void HighlightsRemainDisabled() => - Block!.HighlightsEnabled.Should().BeFalse(); + public void HighlightsRemainDisabled() => Block!.HighlightsEnabled.Should().BeFalse(); [Fact] - public void EmitsWarningPointingToHighlightsOption() - { - Collector.Diagnostics.Should().Contain(d => - d.Message.Contains("Invalid :type: value 'highlight'", StringComparison.Ordinal) && - d.Message.Contains(":highlights:", StringComparison.Ordinal)); - } + public void EmitsWarningPointingToHighlightsOption() => + Collector.Diagnostics + .Should() + .Contain( + d => + d.Message.Contains("Invalid :type: value 'highlight'", StringComparison.Ordinal) && + d.Message.Contains(":highlights:", StringComparison.Ordinal) + ); [Fact] public void RendersDefaultTypeSectionsNotHighlightsOnly() @@ -222,18 +225,19 @@ public void RendersDefaultTypeSectionsNotHighlightsOnly() /// :highlights: + :description-visibility: keep-descriptions shows bodies in the Highlights section. public class ChangelogHighlightsOptionWithDescriptionsTests : DirectiveTest { - public ChangelogHighlightsOptionWithDescriptionsTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogHighlightsOptionWithDescriptionsTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :highlights: :description-visibility: keep-descriptions ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData(ChangelogHighlightsFixtures.BundleYaml)); + """ + ) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData(ChangelogHighlightsFixtures.BundleYaml)); [Fact] - public void ShowsDescriptionInHighlightsSection() => - Html.Should().Contain("This is the highlight description."); + public void ShowsDescriptionInHighlightsSection() => Html.Should().Contain("This is the highlight description."); } /// @@ -241,16 +245,21 @@ public void ShowsDescriptionInHighlightsSection() => /// public class ChangelogKeepHighlightDescriptionsTests : DirectiveTest { - public ChangelogKeepHighlightDescriptionsTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogKeepHighlightDescriptionsTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :highlights: :description-visibility: keep-highlight-descriptions ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -272,19 +281,19 @@ public ChangelogKeepHighlightDescriptionsTests(ITestOutputHelper output) : base( description: This is the regular feature description. prs: - "222222" - """)); + """ + ) + ); [Fact] public void ParsesKeepHighlightDescriptions() => Block!.DescriptionVisibility.Should().Be(ChangelogDescriptionVisibility.KeepHighlightDescriptions); [Fact] - public void ShowsDescriptionInHighlightsSection() => - Html.Should().Contain("This is the highlight description."); + public void ShowsDescriptionInHighlightsSection() => Html.Should().Contain("This is the highlight description."); [Fact] - public void HidesDescriptionsInTypeSections() => - Html.Should().NotContain("This is the regular feature description."); + public void HidesDescriptionsInTypeSections() => Html.Should().NotContain("This is the regular feature description."); [Fact] public void StillRendersTitlesInTypeSections() @@ -298,15 +307,20 @@ public void StillRendersTitlesInTypeSections() /// keep-highlight-descriptions without :highlights: hides descriptions everywhere. public class ChangelogKeepHighlightDescriptionsWithoutHighlightsTests : DirectiveTest { - public ChangelogKeepHighlightDescriptionsWithoutHighlightsTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogKeepHighlightDescriptionsWithoutHighlightsTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :description-visibility: keep-highlight-descriptions ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -328,11 +342,12 @@ public ChangelogKeepHighlightDescriptionsWithoutHighlightsTests(ITestOutputHelpe description: This is the regular feature description. prs: - "222222" - """)); + """ + ) + ); [Fact] - public void OmitsHighlightsSection() => - Html.Should().NotContain("id=\"elasticsearch-9.3.0-highlights\""); + public void OmitsHighlightsSection() => Html.Should().NotContain("id=\"elasticsearch-9.3.0-highlights\""); [Fact] public void HidesAllRecordDescriptions() diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogIndexPageTocTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogIndexPageTocTests.cs index 89d693594a..530a7f765f 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogIndexPageTocTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogIndexPageTocTests.cs @@ -14,7 +14,8 @@ namespace Elastic.Markdown.Tests.Directives; /// Mirrors the elastic-cloud-serverless index page: changelog directive at the top, manual release /// sections below. Separated-type changelog TOC entries must survive page-level TOC merging. /// -public class ChangelogIndexPageTocTests(ITestOutputHelper output) : DirectiveTest(output, +public class ChangelogIndexPageTocTests(ITestOutputHelper output) : DirectiveTest( + output, // language=markdown """ # Serverless changelog [elastic-cloud-serverless-changelog] @@ -29,13 +30,16 @@ public class ChangelogIndexPageTocTests(ITestOutputHelper output) : DirectiveTes ### Features and enhancements [serverless-changelog-04302026-features-enhancements] * Manual feature entry - """) + """ +) { protected override void AddToFileSystem(MockFileSystem fileSystem) { - fileSystem.AddFile("docs/changelog/bundles/2026-05-19.yaml", new MockFileData( - // language=yaml - """ + fileSystem.AddFile( + "docs/changelog/bundles/2026-05-19.yaml", + new MockFileData( + // language=yaml + """ products: - product: cloud-serverless target: 2026-05-19 @@ -58,7 +62,9 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) action: Action. prs: - "222222" - """)); + """ + ) + ); } [Fact] diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogMergeTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogMergeTests.cs index 0bcb6d5066..879e29c629 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogMergeTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogMergeTests.cs @@ -14,17 +14,21 @@ namespace Elastic.Markdown.Tests.Directives; /// public class ChangelogMergeSameTargetTests : DirectiveTest { - public ChangelogMergeSameTargetTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogMergeSameTargetTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) + """ + ) { // Cloud Serverless scenario: multiple repos contributing to the same dated release - FileSystem.AddFile("docs/changelog/bundles/kibana-2025-08-05.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/kibana-2025-08-05.yaml", + new MockFileData( + // language=yaml + """ products: - product: kibana target: 2025-08-05 @@ -38,11 +42,15 @@ public ChangelogMergeSameTargetTests(ITestOutputHelper output) : base(output, - Dashboard prs: - "111111" - """)); - - FileSystem.AddFile("docs/changelog/bundles/elasticsearch-2025-08-05.yaml", new MockFileData( - // language=yaml """ + ) + ); + + FileSystem.AddFile( + "docs/changelog/bundles/elasticsearch-2025-08-05.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 2025-08-05 @@ -63,11 +71,15 @@ public ChangelogMergeSameTargetTests(ITestOutputHelper output) : base(output, target: 2025-08-05 prs: - "222223" - """)); - - FileSystem.AddFile("docs/changelog/bundles/serverless-2025-08-05.yaml", new MockFileData( - // language=yaml """ + ) + ); + + FileSystem.AddFile( + "docs/changelog/bundles/serverless-2025-08-05.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch-serverless target: 2025-08-05 @@ -81,12 +93,16 @@ public ChangelogMergeSameTargetTests(ITestOutputHelper output) : base(output, - API prs: - "333333" - """)); + """ + ) + ); // A different release date with single bundle - FileSystem.AddFile("docs/changelog/bundles/kibana-2025-08-01.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/kibana-2025-08-01.yaml", + new MockFileData( + // language=yaml + """ products: - product: kibana target: 2025-08-01 @@ -98,7 +114,9 @@ public ChangelogMergeSameTargetTests(ITestOutputHelper output) : base(output, target: 2025-08-01 prs: - "444444" - """)); + """ + ) + ); } [Fact] @@ -177,17 +195,21 @@ private static int CountOccurrences(string text, string pattern) /// public class ChangelogMergeDifferentTargetsTests : DirectiveTest { - public ChangelogMergeDifferentTargetsTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogMergeDifferentTargetsTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) + """ + ) { // Bundles with different targets should remain separate - FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -199,11 +221,15 @@ public ChangelogMergeDifferentTargetsTests(ITestOutputHelper output) : base(outp target: 9.3.0 prs: - "111111" - """)); - - FileSystem.AddFile("docs/changelog/bundles/9.2.0.yaml", new MockFileData( - // language=yaml """ + ) + ); + + FileSystem.AddFile( + "docs/changelog/bundles/9.2.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.2.0 @@ -215,11 +241,15 @@ public ChangelogMergeDifferentTargetsTests(ITestOutputHelper output) : base(outp target: 9.2.0 prs: - "222222" - """)); - - FileSystem.AddFile("docs/changelog/bundles/9.1.0.yaml", new MockFileData( - // language=yaml """ + ) + ); + + FileSystem.AddFile( + "docs/changelog/bundles/9.1.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.1.0 @@ -231,7 +261,9 @@ public ChangelogMergeDifferentTargetsTests(ITestOutputHelper output) : base(outp target: 9.1.0 prs: - "333333" - """)); + """ + ) + ); } [Fact] @@ -266,14 +298,19 @@ public void MaintainsSemverOrder() /// public class ChangelogMergeSingleBundleTests : DirectiveTest { - public ChangelogMergeSingleBundleTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogMergeSingleBundleTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -292,19 +329,18 @@ public ChangelogMergeSingleBundleTests(ITestOutputHelper output) : base(output, target: 9.3.0 prs: - "111112" - """)); + """ + ) + ); [Fact] - public void SingleBundleRemainsUnchanged() => - Block!.LoadedBundles.Should().HaveCount(1); + public void SingleBundleRemainsUnchanged() => Block!.LoadedBundles.Should().HaveCount(1); [Fact] - public void SingleBundleHasCorrectVersion() => - Block!.LoadedBundles[0].Version.Should().Be("9.3.0"); + public void SingleBundleHasCorrectVersion() => Block!.LoadedBundles[0].Version.Should().Be("9.3.0"); [Fact] - public void SingleBundleHasAllEntries() => - Block!.LoadedBundles[0].Entries.Should().HaveCount(2); + public void SingleBundleHasAllEntries() => Block!.LoadedBundles[0].Entries.Should().HaveCount(2); [Fact] public void SingleBundleRendersCorrectly() @@ -319,17 +355,21 @@ public void SingleBundleRendersCorrectly() /// public class ChangelogMergeMixedVersionTypesTests : DirectiveTest { - public ChangelogMergeMixedVersionTypesTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogMergeMixedVersionTypesTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) + """ + ) { // Semver version - FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -341,12 +381,16 @@ public ChangelogMergeMixedVersionTypesTests(ITestOutputHelper output) : base(out target: 9.3.0 prs: - "111111" - """)); + """ + ) + ); // Date-based version - FileSystem.AddFile("docs/changelog/bundles/2025-08-05.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/2025-08-05.yaml", + new MockFileData( + // language=yaml + """ products: - product: kibana target: 2025-08-05 @@ -358,7 +402,9 @@ public ChangelogMergeMixedVersionTypesTests(ITestOutputHelper output) : base(out target: 2025-08-05 prs: - "222222" - """)); + """ + ) + ); } [Fact] diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogMissingFileTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogMissingFileTests.cs index dc5820f641..08fdcd99b3 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogMissingFileTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogMissingFileTests.cs @@ -17,17 +17,21 @@ namespace Elastic.Markdown.Tests.Directives; /// public class ChangelogEntryWithoutInlineContentTests : DirectiveTest { - public ChangelogEntryWithoutInlineContentTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogEntryWithoutInlineContentTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) + """ + ) { // Bundle entry carries only file provenance — no inline title/type - FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -35,12 +39,16 @@ public ChangelogEntryWithoutInlineContentTests(ITestOutputHelper output) : base( - file: name: 1234-referenced-entry.yaml checksum: abc123 - """)); + """ + ) + ); // Even when the referenced file exists on disk it must never be read - FileSystem.AddFile("docs/changelog/1234-referenced-entry.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/1234-referenced-entry.yaml", + new MockFileData( + // language=yaml + """ title: A referenced feature type: feature products: @@ -48,26 +56,27 @@ public ChangelogEntryWithoutInlineContentTests(ITestOutputHelper output) : base( target: 9.3.0 prs: - "1234" - """)); + """ + ) + ); } [Fact] public void EmitsErrorNamingBundleAndEntry() => - Collector.Diagnostics.Should().ContainSingle(d => - d.Severity == Severity.Error && - d.Message.Contains("9.3.0.yaml") && - d.Message.Contains("1234-referenced-entry.yaml") && - d.Message.Contains("no inline content")); + Collector.Diagnostics + .Should() + .ContainSingle( + d => + d.Severity == Severity.Error && d.Message.Contains("9.3.0.yaml") && d.Message.Contains("1234-referenced-entry.yaml") && + d.Message.Contains("no inline content") + ); [Fact] public void ErrorIsNotAWarning() => - Collector.Diagnostics.Should().NotContain(d => - d.Severity == Severity.Warning && - d.Message.Contains("1234-referenced-entry.yaml")); + Collector.Diagnostics.Should().NotContain(d => d.Severity == Severity.Warning && d.Message.Contains("1234-referenced-entry.yaml")); [Fact] - public void NeverLoadsTheReferencedFile() => - Block!.LoadedBundles.Should().ContainSingle(b => b.Entries.Count == 0); + public void NeverLoadsTheReferencedFile() => Block!.LoadedBundles.Should().ContainSingle(b => b.Entries.Count == 0); } /// @@ -76,16 +85,20 @@ public void NeverLoadsTheReferencedFile() => /// public class ChangelogInlineEntriesNoErrorTests : DirectiveTest { - public ChangelogInlineEntriesNoErrorTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogInlineEntriesNoErrorTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => + """ + ) => // Bundle has fully inline/resolved entry — no file reference needed - FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -97,13 +110,13 @@ public ChangelogInlineEntriesNoErrorTests(ITestOutputHelper output) : base(outpu target: 9.3.0 prs: - "999" - """)); + """ + ) + ); [Fact] - public void HasNoDiagnostics() => - Collector.Diagnostics.Should().BeEmpty(); + public void HasNoDiagnostics() => Collector.Diagnostics.Should().BeEmpty(); [Fact] - public void LoadsEntries() => - Block!.LoadedBundles.Should().ContainSingle(b => b.Entries.Count == 1); + public void LoadsEntries() => Block!.LoadedBundles.Should().ContainSingle(b => b.Entries.Count == 1); } diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogPathResolutionTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogPathResolutionTests.cs index f3c6b61b95..5945a22148 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogPathResolutionTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogPathResolutionTests.cs @@ -17,14 +17,19 @@ namespace Elastic.Markdown.Tests.Directives; /// public class ChangelogBundlesFolderRelativePathTests : DirectiveTest { - public ChangelogBundlesFolderRelativePathTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogBundlesFolderRelativePathTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} custom/path/bundles ::: - """) => FileSystem.AddFile("docs/custom/path/bundles/1.0.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/custom/path/bundles/1.0.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: test-product target: 1.0.0 @@ -36,7 +41,9 @@ public ChangelogBundlesFolderRelativePathTests(ITestOutputHelper output) : base( target: 1.0.0 prs: - "12345" - """)); + """ + ) + ); [Fact] public void ResolvesRelativePath() => Block!.Found.Should().BeTrue(); @@ -51,14 +58,19 @@ public void PathCombinedWithDocsetRoot() => public class ChangelogBundlesFolderDocsetRootRelativeTests : DirectiveTest { - public ChangelogBundlesFolderDocsetRootRelativeTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogBundlesFolderDocsetRootRelativeTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} /release-notes/versions ::: - """) => FileSystem.AddFile("docs/release-notes/versions/2.0.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/release-notes/versions/2.0.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: test-product target: 2.0.0 @@ -70,7 +82,9 @@ public ChangelogBundlesFolderDocsetRootRelativeTests(ITestOutputHelper output) : target: 2.0.0 prs: - "67890" - """)); + """ + ) + ); [Fact] public void ResolvesDocsetRootRelativePath() => Block!.Found.Should().BeTrue(); @@ -80,8 +94,7 @@ public void SlashPrefixIsTrimmed() => Block!.BundlesFolderPath.Should().EndWith("release-notes/versions".Replace('/', Path.DirectorySeparatorChar)); [Fact] - public void PathDoesNotContainDoubleSlashes() => - Block!.BundlesFolderPath.Should().NotContain("//"); + public void PathDoesNotContainDoubleSlashes() => Block!.BundlesFolderPath.Should().NotContain("//"); [Fact] public void RendersContent() => Html.Should().Contain("Another feature"); @@ -89,18 +102,22 @@ public void PathDoesNotContainDoubleSlashes() => public class ChangelogConfigRelativePathTests : DirectiveTest { - public ChangelogConfigRelativePathTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogConfigRelativePathTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :config: config/my-changelog.yml :type: all ::: - """) + """ + ) { - FileSystem.AddFile("docs/changelog/bundles/1.0.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/1.0.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: test-product target: 1.0.0 @@ -122,16 +139,22 @@ public ChangelogConfigRelativePathTests(ITestOutputHelper output) : base(output, action: Upgrade. prs: - "22222" - """)); - - FileSystem.AddFile("docs/config/my-changelog.yml", new MockFileData( - // language=yaml """ + ) + ); + + FileSystem.AddFile( + "docs/config/my-changelog.yml", + new MockFileData( + // language=yaml + """ rules: publish: exclude_types: - deprecation - """)); + """ + ) + ); } [Fact] @@ -148,17 +171,21 @@ public void RendersAllEntries_NoFiltering() public class ChangelogConfigDocsetRootRelativePathTests : DirectiveTest { - public ChangelogConfigDocsetRootRelativePathTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogConfigDocsetRootRelativePathTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :config: /settings/changelog-config.yml ::: - """) + """ + ) { - FileSystem.AddFile("docs/changelog/bundles/1.0.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/1.0.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: test-product target: 1.0.0 @@ -179,16 +206,22 @@ public ChangelogConfigDocsetRootRelativePathTests(ITestOutputHelper output) : ba - Internal prs: - "44444" - """)); - - FileSystem.AddFile("docs/settings/changelog-config.yml", new MockFileData( - // language=yaml """ + ) + ); + + FileSystem.AddFile( + "docs/settings/changelog-config.yml", + new MockFileData( + // language=yaml + """ rules: publish: exclude_areas: - Internal - """)); + """ + ) + ); } [Fact] @@ -205,14 +238,19 @@ public void RendersAllEntries_NoFiltering() public class ChangelogBundlesFolderNestedRelativePathTests : DirectiveTest { - public ChangelogBundlesFolderNestedRelativePathTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogBundlesFolderNestedRelativePathTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} deeply/nested/path/to/bundles ::: - """) => FileSystem.AddFile("docs/deeply/nested/path/to/bundles/3.0.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/deeply/nested/path/to/bundles/3.0.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: nested-product target: 3.0.0 @@ -224,7 +262,9 @@ public ChangelogBundlesFolderNestedRelativePathTests(ITestOutputHelper output) : target: 3.0.0 prs: - "99999" - """)); + """ + ) + ); [Fact] public void ResolvesDeepNestedPath() => Block!.Found.Should().BeTrue(); @@ -248,14 +288,19 @@ public ChangelogBundlesFolderNestedRelativePathTests(ITestOutputHelper output) : /// public class ChangelogPathEdgeCaseTests : DirectiveTest { - public ChangelogPathEdgeCaseTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogPathEdgeCaseTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ./relative/bundles ::: - """) => FileSystem.AddFile("docs/relative/bundles/1.0.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/relative/bundles/1.0.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: edge-product target: 1.0.0 @@ -267,7 +312,9 @@ public ChangelogPathEdgeCaseTests(ITestOutputHelper output) : base(output, target: 1.0.0 prs: - "55555" - """)); + """ + ) + ); [Fact] public void ResolvesPathWithDotSlashPrefix() => Block!.Found.Should().BeTrue(); @@ -278,17 +325,21 @@ public ChangelogPathEdgeCaseTests(ITestOutputHelper output) : base(output, public class ChangelogConfigAndBundlesRelativePathsTests : DirectiveTest { - public ChangelogConfigAndBundlesRelativePathsTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogConfigAndBundlesRelativePathsTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} bundles/v1 :config: config/changelog.yml ::: - """) + """ + ) { - FileSystem.AddFile("docs/bundles/v1/1.0.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/bundles/v1/1.0.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: combined-product target: 1.0.0 @@ -307,16 +358,22 @@ public ChangelogConfigAndBundlesRelativePathsTests(ITestOutputHelper output) : b target: 1.0.0 prs: - "77777" - """)); - - FileSystem.AddFile("docs/config/changelog.yml", new MockFileData( - // language=yaml """ + ) + ); + + FileSystem.AddFile( + "docs/config/changelog.yml", + new MockFileData( + // language=yaml + """ rules: publish: exclude_types: - other - """)); + """ + ) + ); } [Fact] diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogPrivateLinkBugTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogPrivateLinkBugTests.cs index fc12e504d7..707bdc0b3a 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogPrivateLinkBugTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogPrivateLinkBugTests.cs @@ -14,17 +14,22 @@ namespace Elastic.Markdown.Tests.Directives; /// public class ChangelogPrivateLinkBugTests : DirectiveTest { - public ChangelogPrivateLinkBugTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogPrivateLinkBugTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: deprecation :dropdowns: :description-visibility: keep-descriptions ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -39,7 +44,9 @@ public ChangelogPrivateLinkBugTests(ITestOutputHelper output) : base(output, description: This API will be removed in a future version. impact: Users must update their integration. action: Follow the migration guide. - """)); + """ + ) + ); [Fact] public void DoesNotRenderIncompleteForMoreInformationSentence() @@ -74,17 +81,22 @@ public void StillRendersEntryWithoutLinkSection() /// public class ChangelogMixedLinkBugTests : DirectiveTest { - public ChangelogMixedLinkBugTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogMixedLinkBugTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: deprecation :dropdowns: :description-visibility: keep-descriptions ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -100,7 +112,9 @@ public ChangelogMixedLinkBugTests(ITestOutputHelper output) : base(output, issues: - "# PRIVATE: https://github.com/elastic/cloud/issues/789" - "654321" - """)); + """ + ) + ); [Fact] public void RendersForMoreInformationWithOnlyVisibleLinks() @@ -125,17 +139,22 @@ public void RendersForMoreInformationWithOnlyVisibleLinks() /// public class ChangelogNoLinksTests : DirectiveTest { - public ChangelogNoLinksTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogNoLinksTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: deprecation :dropdowns: :description-visibility: keep-descriptions ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -146,7 +165,9 @@ public ChangelogNoLinksTests(ITestOutputHelper output) : base(output, - product: elasticsearch target: 9.3.0 description: This has no PR or issue references. - """)); + """ + ) + ); [Fact] public void DoesNotRenderForMoreInformationSection() diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogReleaseDatesOptionTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogReleaseDatesOptionTests.cs index ad74f97e39..3af12a2dd5 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogReleaseDatesOptionTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogReleaseDatesOptionTests.cs @@ -11,14 +11,19 @@ namespace Elastic.Markdown.Tests.Directives; /// Tests for the :release-dates: directive option. public class ChangelogReleaseDatesOptionDefaultOffTests : DirectiveTest { - public ChangelogReleaseDatesOptionDefaultOffTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogReleaseDatesOptionDefaultOffTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => FileSystem.AddFile("docs/changelog/bundles/1.34.0.yaml", new MockFileData( - // language=yaml - """ + """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/1.34.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: apm-agent-dotnet target: 1.34.0 @@ -31,32 +36,36 @@ public ChangelogReleaseDatesOptionDefaultOffTests(ITestOutputHelper output) : ba target: 1.34.0 prs: - "500" - """)); + """ + ) + ); [Fact] - public void ReleaseDatesDisabledByDefault() => - Block!.ReleaseDatesEnabled.Should().BeFalse(); + public void ReleaseDatesDisabledByDefault() => Block!.ReleaseDatesEnabled.Should().BeFalse(); [Fact] - public void OmitsReleasedLineWhenFlagOmitted() => - Html.Should().NotContain("Released:"); + public void OmitsReleasedLineWhenFlagOmitted() => Html.Should().NotContain("Released:"); [Fact] - public void StillRendersEntries() => - Html.Should().Contain("Add tracing improvements"); + public void StillRendersEntries() => Html.Should().Contain("Add tracing improvements"); } public class ChangelogReleaseDatesOptionEnabledTests : DirectiveTest { - public ChangelogReleaseDatesOptionEnabledTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogReleaseDatesOptionEnabledTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :release-dates: ::: - """) => FileSystem.AddFile("docs/changelog/bundles/1.34.0.yaml", new MockFileData( - // language=yaml - """ + """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/1.34.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: apm-agent-dotnet target: 1.34.0 @@ -69,28 +78,33 @@ public ChangelogReleaseDatesOptionEnabledTests(ITestOutputHelper output) : base( target: 1.34.0 prs: - "500" - """)); + """ + ) + ); [Fact] - public void ReleaseDatesEnabledWhenFlagPresent() => - Block!.ReleaseDatesEnabled.Should().BeTrue(); + public void ReleaseDatesEnabledWhenFlagPresent() => Block!.ReleaseDatesEnabled.Should().BeTrue(); [Fact] - public void RendersReleasedLineWhenBundleHasReleaseDate() => - Html.Should().Contain("Released: April 9, 2026"); + public void RendersReleasedLineWhenBundleHasReleaseDate() => Html.Should().Contain("Released: April 9, 2026"); } public class ChangelogReleaseDatesOptionEnabledWithoutBundleDateTests : DirectiveTest { - public ChangelogReleaseDatesOptionEnabledWithoutBundleDateTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogReleaseDatesOptionEnabledWithoutBundleDateTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :release-dates: ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -102,23 +116,29 @@ public ChangelogReleaseDatesOptionEnabledWithoutBundleDateTests(ITestOutputHelpe target: 9.3.0 prs: - "100" - """)); + """ + ) + ); [Fact] - public void OmitsReleasedLineWhenBundleHasNoReleaseDate() => - Html.Should().NotContain("Released:"); + public void OmitsReleasedLineWhenBundleHasNoReleaseDate() => Html.Should().NotContain("Released:"); } public class ChangelogReleaseDatesOptionDescriptionStillRendersTests : DirectiveTest { - public ChangelogReleaseDatesOptionDescriptionStillRendersTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogReleaseDatesOptionDescriptionStillRendersTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => FileSystem.AddFile("docs/changelog/bundles/1.34.0.yaml", new MockFileData( - // language=yaml - """ + """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/1.34.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: apm-agent-dotnet target: 1.34.0 @@ -133,13 +153,13 @@ This release includes tracing improvements and bug fixes. target: 1.34.0 prs: - "500" - """)); + """ + ) + ); [Fact] - public void OmitsReleasedLineWhenFlagOmitted() => - Html.Should().NotContain("Released:"); + public void OmitsReleasedLineWhenFlagOmitted() => Html.Should().NotContain("Released:"); [Fact] - public void RendersBundleDescription() => - Html.Should().Contain("This release includes tracing improvements and bug fixes."); + public void RendersBundleDescription() => Html.Should().Contain("This release includes tracing improvements and bug fixes."); } diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogSubsectionsTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogSubsectionsTests.cs index c9bb1bc4c8..f4327b0bef 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogSubsectionsTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogSubsectionsTests.cs @@ -10,14 +10,19 @@ namespace Elastic.Markdown.Tests.Directives; public class ChangelogSubsectionsDisabledByDefaultTests : DirectiveTest { - public ChangelogSubsectionsDisabledByDefaultTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogSubsectionsDisabledByDefaultTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -40,7 +45,9 @@ public ChangelogSubsectionsDisabledByDefaultTests(ITestOutputHelper output) : ba - Indexing prs: - "222222" - """)); + """ + ) + ); [Fact] public void SubsectionsPropertyDefaultsToFalse() => Block!.Subsections.Should().BeFalse(); @@ -64,15 +71,20 @@ public void RendersEntriesWithoutGrouping() public class ChangelogSubsectionsEnabledTests : DirectiveTest { - public ChangelogSubsectionsEnabledTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogSubsectionsEnabledTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :subsections: ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -95,7 +107,9 @@ public ChangelogSubsectionsEnabledTests(ITestOutputHelper output) : base(output, - Indexing prs: - "222222" - """)); + """ + ) + ); [Fact] public void SubsectionsPropertyIsTrue() => Block!.Subsections.Should().BeTrue(); @@ -119,15 +133,20 @@ public void RendersEntriesUnderCorrectAreas() public class ChangelogSubsectionsExplicitFalseTests : DirectiveTest { - public ChangelogSubsectionsExplicitFalseTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogSubsectionsExplicitFalseTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :subsections: false ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -141,7 +160,9 @@ public ChangelogSubsectionsExplicitFalseTests(ITestOutputHelper output) : base(o - Search prs: - "111111" - """)); + """ + ) + ); [Fact] public void SubsectionsPropertyIsFalse() => Block!.Subsections.Should().BeFalse(); @@ -160,15 +181,20 @@ public ChangelogSubsectionsExplicitFalseTests(ITestOutputHelper output) : base(o /// public class ChangelogSubsectionsNoAreaRulesTests : DirectiveTest { - public ChangelogSubsectionsNoAreaRulesTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogSubsectionsNoAreaRulesTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :subsections: ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -183,7 +209,9 @@ public ChangelogSubsectionsNoAreaRulesTests(ITestOutputHelper output) : base(out - Monitoring - Security pr: "111111" - """)); + """ + ) + ); [Fact] public void GroupsUnderFirstArea() diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogTocFilteringTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogTocFilteringTests.cs index c389cc9b99..61fada3446 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogTocFilteringTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogTocFilteringTests.cs @@ -14,16 +14,20 @@ namespace Elastic.Markdown.Tests.Directives; /// public class ChangelogPublishBlockerFiltersTocTests : DirectiveTest { - public ChangelogPublishBlockerFiltersTocTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogPublishBlockerFiltersTocTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) + """ + ) { - FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -49,18 +53,24 @@ public ChangelogPublishBlockerFiltersTocTests(ITestOutputHelper output) : base(o target: 9.3.0 prs: - "333333" - """)); + """ + ) + ); // rules.publish in config is ignored by the directive - FileSystem.AddFile("docs/changelog.yml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog.yml", + new MockFileData( + // language=yaml + """ rules: publish: exclude_types: - docs - other - """)); + """ + ) + ); } [Fact] @@ -124,16 +134,20 @@ public void HtmlContainsAllSections() /// public class ChangelogHideFeaturesFiltersTocTests : DirectiveTest { - public ChangelogHideFeaturesFiltersTocTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogHideFeaturesFiltersTocTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => + """ + ) => // Bundle with hide-features that filters out all "other" entries - FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -164,7 +178,9 @@ public ChangelogHideFeaturesFiltersTocTests(ITestOutputHelper output) : base(out target: 9.3.0 prs: - "333333" - """)); + """ + ) + ); [Fact] public void TocExcludesHiddenOtherSection() @@ -209,14 +225,19 @@ public void HtmlMatchesTocFiltering() /// public class ChangelogPartialFilterRetainsTocTests : DirectiveTest { - public ChangelogPartialFilterRetainsTocTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogPartialFilterRetainsTocTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -238,7 +259,9 @@ public ChangelogPartialFilterRetainsTocTests(ITestOutputHelper output) : base(ou target: 9.3.0 prs: - "222222" - """)); + """ + ) + ); [Fact] public void TocRetainsSectionWhenSomeEntriesRemain() @@ -267,16 +290,20 @@ public void HtmlShowsVisibleEntryOnly() /// public class ChangelogCombinedFiltersFilterTocTests : DirectiveTest { - public ChangelogCombinedFiltersFilterTocTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogCombinedFiltersFilterTocTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) + """ + ) { - FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -305,17 +332,23 @@ public ChangelogCombinedFiltersFilterTocTests(ITestOutputHelper output) : base(o target: 9.3.0 prs: - "333333" - """)); + """ + ) + ); // rules.publish is ignored by the directive; only hide-features applies - FileSystem.AddFile("docs/changelog.yml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog.yml", + new MockFileData( + // language=yaml + """ rules: publish: exclude_types: - docs - """)); + """ + ) + ); } [Fact] @@ -367,16 +400,20 @@ public void HtmlMatchesTocAndAnchors() /// public class ChangelogPublishBlockerAreaFiltersTocTests : DirectiveTest { - public ChangelogPublishBlockerAreaFiltersTocTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogPublishBlockerAreaFiltersTocTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) + """ + ) { - FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -406,17 +443,23 @@ public ChangelogPublishBlockerAreaFiltersTocTests(ITestOutputHelper output) : ba - Internal prs: - "333333" - """)); + """ + ) + ); // rules.publish in config is ignored by the directive - FileSystem.AddFile("docs/changelog.yml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog.yml", + new MockFileData( + // language=yaml + """ rules: publish: exclude_areas: - Internal - """)); + """ + ) + ); } [Fact] @@ -459,14 +502,19 @@ public void AnchorsIncludeAllSections() /// public class ChangelogAllEntriesFilteredTocTests : DirectiveTest { - public ChangelogAllEntriesFilteredTocTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogAllEntriesFilteredTocTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -489,7 +537,9 @@ public ChangelogAllEntriesFilteredTocTests(ITestOutputHelper output) : base(outp target: 9.3.0 prs: - "222222" - """)); + """ + ) + ); [Fact] public void OmitsVersionFromTocWhenNoRenderableEntries() @@ -499,10 +549,7 @@ public void OmitsVersionFromTocWhenNoRenderableEntries() } [Fact] - public void OmitsVersionFromRenderedOutput() - { - Html.Should().NotContain("9.3.0"); - } + public void OmitsVersionFromRenderedOutput() => Html.Should().NotContain("9.3.0"); [Fact] public void NoAnchorsGenerated() @@ -518,17 +565,21 @@ public void NoAnchorsGenerated() /// public class ChangelogMultipleBundlesTocFilteringTests : DirectiveTest { - public ChangelogMultipleBundlesTocFilteringTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogMultipleBundlesTocFilteringTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) + """ + ) { // 9.3.0 has docs entries that will be blocked - FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -547,12 +598,16 @@ public ChangelogMultipleBundlesTocFilteringTests(ITestOutputHelper output) : bas target: 9.3.0 prs: - "222222" - """)); + """ + ) + ); // 9.2.0 only has docs entries (all will be blocked) - FileSystem.AddFile("docs/changelog/bundles/9.2.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/9.2.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.2.0 @@ -564,17 +619,23 @@ public ChangelogMultipleBundlesTocFilteringTests(ITestOutputHelper output) : bas target: 9.2.0 prs: - "333333" - """)); + """ + ) + ); // rules.publish is ignored by the directive - FileSystem.AddFile("docs/changelog.yml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog.yml", + new MockFileData( + // language=yaml + """ rules: publish: exclude_types: - docs - """)); + """ + ) + ); } [Fact] diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogTypeFilterTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogTypeFilterTests.cs index cf2331b257..25ed1f3e81 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogTypeFilterTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogTypeFilterTests.cs @@ -18,14 +18,19 @@ namespace Elastic.Markdown.Tests.Directives; /// public class ChangelogTypeFilterDefaultTests : DirectiveTest { - public ChangelogTypeFilterDefaultTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogTypeFilterDefaultTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -74,13 +79,12 @@ public ChangelogTypeFilterDefaultTests(ITestOutputHelper output) : base(output, action: Use new feature. prs: - "555555" - """)); + """ + ) + ); [Fact] - public void DefaultBehaviorExcludesSeparatedTypes() - { - Block!.TypeFilter.Should().Be(ChangelogTypeFilter.Default); - } + public void DefaultBehaviorExcludesSeparatedTypes() => Block!.TypeFilter.Should().Be(ChangelogTypeFilter.Default); [Fact] public void DefaultBehaviorShowsFeatures() @@ -123,15 +127,20 @@ public void DefaultBehaviorExcludesDeprecations() /// public class ChangelogTypeFilterAllTests : DirectiveTest { - public ChangelogTypeFilterAllTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogTypeFilterAllTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: all ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -180,13 +189,12 @@ public ChangelogTypeFilterAllTests(ITestOutputHelper output) : base(output, action: Use new feature. prs: - "555555" - """)); + """ + ) + ); [Fact] - public void TypeFilterIsAll() - { - Block!.TypeFilter.Should().Be(ChangelogTypeFilter.All); - } + public void TypeFilterIsAll() => Block!.TypeFilter.Should().Be(ChangelogTypeFilter.All); [Fact] public void ShowsAllEntryTypes() @@ -219,8 +227,10 @@ public void SeparatedTypeTocSlugsMatchHtmlIds() var tocItems = Block!.GeneratedTableOfContent.ToList(); foreach (var item in tocItems.Where(t => t.Level == 3)) { - Html.Should().Contain($"id=\"{item.Slug}\"", - $"TOC item '{item.Heading}' (slug '{item.Slug}') must have a matching heading-wrapper id"); + Html.Should().Contain( + $"id=\"{item.Slug}\"", + $"TOC item '{item.Heading}' (slug '{item.Slug}') must have a matching heading-wrapper id" + ); } } } @@ -230,15 +240,20 @@ public void SeparatedTypeTocSlugsMatchHtmlIds() /// public class ChangelogTypeFilterBreakingChangeTests : DirectiveTest { - public ChangelogTypeFilterBreakingChangeTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogTypeFilterBreakingChangeTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: breaking-change ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -270,13 +285,12 @@ public ChangelogTypeFilterBreakingChangeTests(ITestOutputHelper output) : base(o action: Workaround available. prs: - "444444" - """)); + """ + ) + ); [Fact] - public void TypeFilterIsBreakingChange() - { - Block!.TypeFilter.Should().Be(ChangelogTypeFilter.BreakingChange); - } + public void TypeFilterIsBreakingChange() => Block!.TypeFilter.Should().Be(ChangelogTypeFilter.BreakingChange); [Fact] public void ShowsBreakingChanges() @@ -301,15 +315,20 @@ public void ExcludesOtherTypes() /// public class ChangelogTypeFilterDeprecationTests : DirectiveTest { - public ChangelogTypeFilterDeprecationTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogTypeFilterDeprecationTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: deprecation ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -341,13 +360,12 @@ public ChangelogTypeFilterDeprecationTests(ITestOutputHelper output) : base(outp action: Migrate to new API. prs: - "666666" - """)); + """ + ) + ); [Fact] - public void TypeFilterIsDeprecation() - { - Block!.TypeFilter.Should().Be(ChangelogTypeFilter.Deprecation); - } + public void TypeFilterIsDeprecation() => Block!.TypeFilter.Should().Be(ChangelogTypeFilter.Deprecation); [Fact] public void ShowsDeprecations() @@ -371,15 +389,20 @@ public void ExcludesOtherTypes() /// public class ChangelogTypeFilterKnownIssueTests : DirectiveTest { - public ChangelogTypeFilterKnownIssueTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogTypeFilterKnownIssueTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: known-issue ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -411,13 +434,12 @@ public ChangelogTypeFilterKnownIssueTests(ITestOutputHelper output) : base(outpu action: Different workaround. prs: - "555555" - """)); + """ + ) + ); [Fact] - public void TypeFilterIsKnownIssue() - { - Block!.TypeFilter.Should().Be(ChangelogTypeFilter.KnownIssue); - } + public void TypeFilterIsKnownIssue() => Block!.TypeFilter.Should().Be(ChangelogTypeFilter.KnownIssue); [Fact] public void ShowsKnownIssues() @@ -441,15 +463,20 @@ public void ExcludesOtherTypes() /// public class ChangelogTypeFilterInvalidTests : DirectiveTest { - public ChangelogTypeFilterInvalidTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogTypeFilterInvalidTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: invalid-value ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -471,19 +498,15 @@ public ChangelogTypeFilterInvalidTests(ITestOutputHelper output) : base(output, action: Action. prs: - "222222" - """)); + """ + ) + ); [Fact] - public void FallsBackToDefaultBehavior() - { - Block!.TypeFilter.Should().Be(ChangelogTypeFilter.Default); - } + public void FallsBackToDefaultBehavior() => Block!.TypeFilter.Should().Be(ChangelogTypeFilter.Default); [Fact] - public void EmitsWarningForInvalidValue() - { - Collector.Diagnostics.Should().Contain(d => d.Message.Contains("Invalid :type: value")); - } + public void EmitsWarningForInvalidValue() => Collector.Diagnostics.Should().Contain(d => d.Message.Contains("Invalid :type: value")); [Fact] public void DefaultBehaviorIsApplied() @@ -499,15 +522,20 @@ public void DefaultBehaviorIsApplied() /// public class ChangelogTypeFilterCaseInsensitiveTests : DirectiveTest { - public ChangelogTypeFilterCaseInsensitiveTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogTypeFilterCaseInsensitiveTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: ALL ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -529,13 +557,12 @@ public ChangelogTypeFilterCaseInsensitiveTests(ITestOutputHelper output) : base( action: Action. prs: - "222222" - """)); + """ + ) + ); [Fact] - public void AcceptsUppercaseAll() - { - Block!.TypeFilter.Should().Be(ChangelogTypeFilter.All); - } + public void AcceptsUppercaseAll() => Block!.TypeFilter.Should().Be(ChangelogTypeFilter.All); [Fact] public void ShowsAllTypes() @@ -550,16 +577,21 @@ public void ShowsAllTypes() /// public class ChangelogTypeFilterWithSubsectionsTests : DirectiveTest { - public ChangelogTypeFilterWithSubsectionsTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogTypeFilterWithSubsectionsTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: all :subsections: ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -592,7 +624,9 @@ public ChangelogTypeFilterWithSubsectionsTests(ITestOutputHelper output) : base( action: Action. prs: - "333333" - """)); + """ + ) + ); [Fact] public void TypeFilterAndSubsectionsBothWork() @@ -616,15 +650,20 @@ public void ShowsAllTypesWithSubsections() /// public class ChangelogTypeFilterGeneratedAnchorsTests : DirectiveTest { - public ChangelogTypeFilterGeneratedAnchorsTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogTypeFilterGeneratedAnchorsTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: breaking-change ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -646,7 +685,9 @@ public ChangelogTypeFilterGeneratedAnchorsTests(ITestOutputHelper output) : base action: Action. prs: - "222222" - """)); + """ + ) + ); [Fact] public void GeneratedAnchorsRespectTypeFilter() @@ -663,15 +704,20 @@ public void GeneratedAnchorsRespectTypeFilter() /// public class ChangelogTypeFilterTableOfContentsTests : DirectiveTest { - public ChangelogTypeFilterTableOfContentsTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogTypeFilterTableOfContentsTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: deprecation ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -693,7 +739,9 @@ public ChangelogTypeFilterTableOfContentsTests(ITestOutputHelper output) : base( action: Action. prs: - "222222" - """)); + """ + ) + ); [Fact] public void TableOfContentsRespectTypeFilter() @@ -711,15 +759,20 @@ public void TableOfContentsRespectTypeFilter() /// public class ChangelogTypeFilterEmptyKnownIssueTests : DirectiveTest { - public ChangelogTypeFilterEmptyKnownIssueTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogTypeFilterEmptyKnownIssueTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: known-issue ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -731,7 +784,9 @@ public ChangelogTypeFilterEmptyKnownIssueTests(ITestOutputHelper output) : base( target: 9.3.0 prs: - "111111" - """)); + """ + ) + ); [Fact] public void OmitsEmptyVersionBlock() @@ -746,15 +801,20 @@ public void OmitsEmptyVersionBlock() /// public class ChangelogTypeFilterEmptyBreakingChangeTests : DirectiveTest { - public ChangelogTypeFilterEmptyBreakingChangeTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogTypeFilterEmptyBreakingChangeTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: breaking-change ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -766,7 +826,9 @@ public ChangelogTypeFilterEmptyBreakingChangeTests(ITestOutputHelper output) : b target: 9.3.0 prs: - "111111" - """)); + """ + ) + ); [Fact] public void OmitsEmptyVersionBlock() @@ -781,15 +843,20 @@ public void OmitsEmptyVersionBlock() /// public class ChangelogTypeFilterEmptyDeprecationTests : DirectiveTest { - public ChangelogTypeFilterEmptyDeprecationTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogTypeFilterEmptyDeprecationTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: deprecation ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -801,7 +868,9 @@ public ChangelogTypeFilterEmptyDeprecationTests(ITestOutputHelper output) : base target: 9.3.0 prs: - "111111" - """)); + """ + ) + ); [Fact] public void OmitsEmptyVersionBlock() @@ -816,14 +885,19 @@ public void OmitsEmptyVersionBlock() /// public class ChangelogTypeFilterEmptyDefaultTests : DirectiveTest { - public ChangelogTypeFilterEmptyDefaultTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogTypeFilterEmptyDefaultTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -838,7 +912,9 @@ public ChangelogTypeFilterEmptyDefaultTests(ITestOutputHelper output) : base(out action: Follow guide. prs: - "111111" - """)); + """ + ) + ); [Fact] public void OmitsEmptyVersionBlock() @@ -853,20 +929,27 @@ public void OmitsEmptyVersionBlock() /// public class ChangelogTypeFilterEmptyAllTests : DirectiveTest { - public ChangelogTypeFilterEmptyAllTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogTypeFilterEmptyAllTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: all ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 entries: [] - """)); + """ + ) + ); [Fact] public void OmitsEmptyVersionBlock() @@ -881,17 +964,21 @@ public void OmitsEmptyVersionBlock() /// public class ChangelogTypeFilterMixedBundlesEmptyOmissionTests : DirectiveTest { - public ChangelogTypeFilterMixedBundlesEmptyOmissionTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogTypeFilterMixedBundlesEmptyOmissionTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: known-issue ::: - """) + """ + ) { - FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -906,10 +993,14 @@ public ChangelogTypeFilterMixedBundlesEmptyOmissionTests(ITestOutputHelper outpu action: Workaround available. prs: - "444444" - """)); - FileSystem.AddFile("docs/changelog/bundles/9.2.0.yaml", new MockFileData( - // language=yaml """ + ) + ); + FileSystem.AddFile( + "docs/changelog/bundles/9.2.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.2.0 @@ -921,7 +1012,9 @@ public ChangelogTypeFilterMixedBundlesEmptyOmissionTests(ITestOutputHelper outpu target: 9.2.0 prs: - "111111" - """)); + """ + ) + ); } [Fact] @@ -938,22 +1031,29 @@ public void RendersOnlyPopulatedVersions() /// public class ChangelogEmptyBundleWithDescriptionTests : DirectiveTest { - public ChangelogEmptyBundleWithDescriptionTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogEmptyBundleWithDescriptionTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: all ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 description: | This release was pulled due to critical issues. entries: [] - """)); + """ + ) + ); [Fact] public void ShowsVersionAndDescriptionWithoutPlaceholder() @@ -969,21 +1069,28 @@ public void ShowsVersionAndDescriptionWithoutPlaceholder() /// public class ChangelogEmptyBundleWithReleaseDateOnlyTests : DirectiveTest { - public ChangelogEmptyBundleWithReleaseDateOnlyTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogEmptyBundleWithReleaseDateOnlyTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: all ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 release-date: "2026-04-09" entries: [] - """)); + """ + ) + ); [Fact] public void OmitsVersionBlockWhenOnlyReleaseDate() @@ -998,15 +1105,20 @@ public void OmitsVersionBlockWhenOnlyReleaseDate() /// public class ChangelogDedicatedPageIgnoresDescriptionTests : DirectiveTest { - public ChangelogDedicatedPageIgnoresDescriptionTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogDedicatedPageIgnoresDescriptionTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: deprecation ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -1020,7 +1132,9 @@ public ChangelogDedicatedPageIgnoresDescriptionTests(ITestOutputHelper output) : target: 9.3.0 prs: - "111111" - """)); + """ + ) + ); [Fact] public void OmitsVersionBlockWhenNoMatchingEntries() @@ -1035,16 +1149,21 @@ public void OmitsVersionBlockWhenNoMatchingEntries() /// public class ChangelogDedicatedPageWithSubsectionsTests : DirectiveTest { - public ChangelogDedicatedPageWithSubsectionsTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogDedicatedPageWithSubsectionsTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} :type: deprecation :subsections: ::: - """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml """ + ) => + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -1073,7 +1192,9 @@ public ChangelogDedicatedPageWithSubsectionsTests(ITestOutputHelper output) : ba action: Update config. prs: - "666666" - """)); + """ + ) + ); [Fact] public void GroupsEntriesByAreaWithoutSectionHeading() diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogVersionSortingTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogVersionSortingTests.cs index 86d5fc2c5f..50b0e71ff6 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogVersionSortingTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogVersionSortingTests.cs @@ -10,17 +10,21 @@ namespace Elastic.Markdown.Tests.Directives; public class ChangelogDateVersionedBundlesTests : DirectiveTest { - public ChangelogDateVersionedBundlesTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogDateVersionedBundlesTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) + """ + ) { // Create multiple bundles with date-based versions (Cloud Serverless style) - FileSystem.AddFile("docs/changelog/bundles/2025-08-01.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/2025-08-01.yaml", + new MockFileData( + // language=yaml + """ products: - product: cloud-serverless target: 2025-08-01 @@ -32,11 +36,15 @@ public ChangelogDateVersionedBundlesTests(ITestOutputHelper output) : base(outpu target: 2025-08-01 prs: - "111111" - """)); - - FileSystem.AddFile("docs/changelog/bundles/2025-08-15.yaml", new MockFileData( - // language=yaml """ + ) + ); + + FileSystem.AddFile( + "docs/changelog/bundles/2025-08-15.yaml", + new MockFileData( + // language=yaml + """ products: - product: cloud-serverless target: 2025-08-15 @@ -48,11 +56,15 @@ public ChangelogDateVersionedBundlesTests(ITestOutputHelper output) : base(outpu target: 2025-08-15 prs: - "222222" - """)); - - FileSystem.AddFile("docs/changelog/bundles/2025-08-05.yaml", new MockFileData( - // language=yaml """ + ) + ); + + FileSystem.AddFile( + "docs/changelog/bundles/2025-08-05.yaml", + new MockFileData( + // language=yaml + """ products: - product: cloud-serverless target: 2025-08-05 @@ -64,7 +76,9 @@ public ChangelogDateVersionedBundlesTests(ITestOutputHelper output) : base(outpu target: 2025-08-05 prs: - "333333" - """)); + """ + ) + ); } [Fact] @@ -101,17 +115,21 @@ public void RendersEntriesForDateVersions() public class ChangelogMixedVersionTypesTests : DirectiveTest { - public ChangelogMixedVersionTypesTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogMixedVersionTypesTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) + """ + ) { // Create bundles with mixed version types (semver and dates) - FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/9.3.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.3.0 @@ -123,11 +141,15 @@ public ChangelogMixedVersionTypesTests(ITestOutputHelper output) : base(output, target: 9.3.0 prs: - "111111" - """)); - - FileSystem.AddFile("docs/changelog/bundles/9.2.0.yaml", new MockFileData( - // language=yaml """ + ) + ); + + FileSystem.AddFile( + "docs/changelog/bundles/9.2.0.yaml", + new MockFileData( + // language=yaml + """ products: - product: elasticsearch target: 9.2.0 @@ -139,11 +161,15 @@ public ChangelogMixedVersionTypesTests(ITestOutputHelper output) : base(output, target: 9.2.0 prs: - "222222" - """)); - - FileSystem.AddFile("docs/changelog/bundles/2025-08-05.yaml", new MockFileData( - // language=yaml """ + ) + ); + + FileSystem.AddFile( + "docs/changelog/bundles/2025-08-05.yaml", + new MockFileData( + // language=yaml + """ products: - product: cloud-serverless target: 2025-08-05 @@ -155,11 +181,15 @@ public ChangelogMixedVersionTypesTests(ITestOutputHelper output) : base(output, target: 2025-08-05 prs: - "333333" - """)); - - FileSystem.AddFile("docs/changelog/bundles/2025-07-01.yaml", new MockFileData( - // language=yaml """ + ) + ); + + FileSystem.AddFile( + "docs/changelog/bundles/2025-07-01.yaml", + new MockFileData( + // language=yaml + """ products: - product: cloud-serverless target: 2025-07-01 @@ -171,7 +201,9 @@ public ChangelogMixedVersionTypesTests(ITestOutputHelper output) : base(output, target: 2025-07-01 prs: - "444444" - """)); + """ + ) + ); } [Fact] @@ -223,16 +255,20 @@ public void RendersAllEntries() /// public class ChangelogYearMonthVersionTests : DirectiveTest { - public ChangelogYearMonthVersionTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogYearMonthVersionTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) + """ + ) { - FileSystem.AddFile("docs/changelog/bundles/2025-12.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/2025-12.yaml", + new MockFileData( + // language=yaml + """ products: - product: cloud-hosted target: 2025-12 @@ -244,11 +280,15 @@ public ChangelogYearMonthVersionTests(ITestOutputHelper output) : base(output, target: 2025-12 prs: - "111111" - """)); - - FileSystem.AddFile("docs/changelog/bundles/2025-10.yaml", new MockFileData( - // language=yaml """ + ) + ); + + FileSystem.AddFile( + "docs/changelog/bundles/2025-10.yaml", + new MockFileData( + // language=yaml + """ products: - product: cloud-hosted target: 2025-10 @@ -260,11 +300,15 @@ public ChangelogYearMonthVersionTests(ITestOutputHelper output) : base(output, target: 2025-10 prs: - "222222" - """)); - - FileSystem.AddFile("docs/changelog/bundles/2025-08.yaml", new MockFileData( - // language=yaml """ + ) + ); + + FileSystem.AddFile( + "docs/changelog/bundles/2025-08.yaml", + new MockFileData( + // language=yaml + """ products: - product: cloud-hosted target: 2025-08 @@ -283,7 +327,9 @@ public ChangelogYearMonthVersionTests(ITestOutputHelper output) : base(output, target: 2025-08 prs: - "333334" - """)); + """ + ) + ); } [Fact] @@ -362,17 +408,21 @@ public void TocSlugMatchesHeadingId() public class ChangelogRawVersionFallbackTests : DirectiveTest { - public ChangelogRawVersionFallbackTests(ITestOutputHelper output) : base(output, - // language=markdown - """ + public ChangelogRawVersionFallbackTests(ITestOutputHelper output) : base( + output, + // language=markdown + """ :::{changelog} ::: - """) + """ + ) { // Create bundles with non-standard version formats (edge case) - FileSystem.AddFile("docs/changelog/bundles/release-alpha.yaml", new MockFileData( - // language=yaml - """ + FileSystem.AddFile( + "docs/changelog/bundles/release-alpha.yaml", + new MockFileData( + // language=yaml + """ products: - product: experimental target: release-alpha @@ -384,11 +434,15 @@ public ChangelogRawVersionFallbackTests(ITestOutputHelper output) : base(output, target: release-alpha prs: - "111111" - """)); - - FileSystem.AddFile("docs/changelog/bundles/release-beta.yaml", new MockFileData( - // language=yaml """ + ) + ); + + FileSystem.AddFile( + "docs/changelog/bundles/release-beta.yaml", + new MockFileData( + // language=yaml + """ products: - product: experimental target: release-beta @@ -400,7 +454,9 @@ public ChangelogRawVersionFallbackTests(ITestOutputHelper output) : base(output, target: release-beta prs: - "222222" - """)); + """ + ) + ); } [Fact] diff --git a/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs b/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs index d705229689..ea9c8dcaec 100644 --- a/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs @@ -12,17 +12,18 @@ namespace Elastic.Markdown.Tests.Directives; public class CsvIncludeTests : DirectiveTest { - public CsvIncludeTests(ITestOutputHelper output) : base(output, -""" + public CsvIncludeTests(ITestOutputHelper output) : base(output, """ :::{csv-include} test-data.csv ::: """) => // Add a test CSV file to the mock file system - FileSystem.AddFile("docs/test-data.csv", new MockFileData( -@"Name,Age,City + FileSystem.AddFile( + "docs/test-data.csv", + new MockFileData(@"Name,Age,City John Doe,30,New York Jane Smith,25,Los Angeles -Bob Johnson,35,Chicago")); +Bob Johnson,35,Chicago") + ); [Fact] public void ParsesCsvFileBlock() => Block.Should().NotBeNull(); @@ -39,7 +40,11 @@ public CsvIncludeTests(ITestOutputHelper output) : base(output, [Fact] public void ParsesCsvDataCorrectly() { - var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, CheckoutsFileSystem.FromWorkingDirectory(FileSystem)).ToList(); + var csvData = CsvReader.ReadCsvFile( + Block!.CsvFilePath!, + Block.Separator, + CheckoutsFileSystem.FromWorkingDirectory(FileSystem) + ).ToList(); csvData.Should().HaveCount(4); csvData[0].Should().BeEquivalentTo(["Name", "Age", "City"]); csvData[1].Should().BeEquivalentTo(["John Doe", "30", "New York"]); @@ -53,14 +58,15 @@ public void ParsesCsvDataCorrectly() public class CsvIncludeWithOptionsTests : DirectiveTest { - public CsvIncludeWithOptionsTests(ITestOutputHelper output) : base(output, -""" + public CsvIncludeWithOptionsTests(ITestOutputHelper output) : base( + output, + """ :::{csv-include} test-data.csv :caption: Sample User Data :separator: ; ::: -""") => FileSystem.AddFile("docs/test-data.csv", new MockFileData( -@"Name;Age;City +""" + ) => FileSystem.AddFile("docs/test-data.csv", new MockFileData(@"Name;Age;City John Doe;30;New York Jane Smith;25;Los Angeles")); @@ -73,7 +79,11 @@ public CsvIncludeWithOptionsTests(ITestOutputHelper output) : base(output, [Fact] public void ParsesWithCustomSeparator() { - var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, CheckoutsFileSystem.FromWorkingDirectory(FileSystem)).ToList(); + var csvData = CsvReader.ReadCsvFile( + Block!.CsvFilePath!, + Block.Separator, + CheckoutsFileSystem.FromWorkingDirectory(FileSystem) + ).ToList(); csvData.Should().HaveCount(3); csvData[0].Should().BeEquivalentTo(["Name", "Age", "City"]); csvData[1].Should().BeEquivalentTo(["John Doe", "30", "New York"]); @@ -83,19 +93,27 @@ public void ParsesWithCustomSeparator() public class CsvIncludeWithQuotesTests : DirectiveTest { - public CsvIncludeWithQuotesTests(ITestOutputHelper output) : base(output, -""" + public CsvIncludeWithQuotesTests(ITestOutputHelper output) : base(output, """ :::{csv-include} test-data.csv ::: -""") => FileSystem.AddFile("docs/test-data.csv", new MockFileData( -@"Name,Description,Location +""") => + FileSystem.AddFile( + "docs/test-data.csv", + new MockFileData( + @"Name,Description,Location John Doe,""Software Engineer, Senior"",New York -Jane Smith,""Product Manager, Lead"",Los Angeles")); +Jane Smith,""Product Manager, Lead"",Los Angeles" + ) + ); [Fact] public void HandlesQuotedFieldsWithCommas() { - var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, CheckoutsFileSystem.FromWorkingDirectory(FileSystem)).ToList(); + var csvData = CsvReader.ReadCsvFile( + Block!.CsvFilePath!, + Block.Separator, + CheckoutsFileSystem.FromWorkingDirectory(FileSystem) + ).ToList(); csvData.Should().HaveCount(3); csvData[0].Should().BeEquivalentTo(["Name", "Description", "Location"]); csvData[1].Should().BeEquivalentTo(["John Doe", "Software Engineer, Senior", "New York"]); @@ -105,19 +123,25 @@ public void HandlesQuotedFieldsWithCommas() public class CsvIncludeWithEscapedQuotesTests : DirectiveTest { - public CsvIncludeWithEscapedQuotesTests(ITestOutputHelper output) : base(output, -""" + public CsvIncludeWithEscapedQuotesTests(ITestOutputHelper output) : base(output, """ :::{csv-include} test-data.csv ::: -""") => FileSystem.AddFile("docs/test-data.csv", new MockFileData( -@"Name,Description +""") => + FileSystem.AddFile( + "docs/test-data.csv", + new MockFileData(@"Name,Description John Doe,""He said """"Hello World"""" today"" -Jane Smith,""She replied """"Goodbye""""")); +Jane Smith,""She replied """"Goodbye""""") + ); [Fact] public void HandlesEscapedQuotes() { - var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, CheckoutsFileSystem.FromWorkingDirectory(FileSystem)).ToList(); + var csvData = CsvReader.ReadCsvFile( + Block!.CsvFilePath!, + Block.Separator, + CheckoutsFileSystem.FromWorkingDirectory(FileSystem) + ).ToList(); csvData.Should().HaveCount(3); csvData[0].Should().BeEquivalentTo(["Name", "Description"]); csvData[1].Should().BeEquivalentTo(["John Doe", "He said \"Hello World\" today"]); @@ -125,33 +149,34 @@ public void HandlesEscapedQuotes() } } -public class CsvIncludeRenderLinksTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class CsvIncludeRenderLinksTests(ITestOutputHelper output) : DirectiveTest(output, """ ::::{csv-include} test-data.csv :::: """) { protected override void AddToFileSystem(MockFileSystem fileSystem) => - fileSystem.AddFile("docs/test-data.csv", new MockFileData( -@"Name,Link + fileSystem.AddFile("docs/test-data.csv", new MockFileData(@"Name,Link Search,[Text](https://www.google.com)")); [Fact] public void RendersMarkdownLinkAsLink() => Html.Should().Contain(">Text"); } -public class CsvIncludeWithHtmlBreaksTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class CsvIncludeWithHtmlBreaksTests(ITestOutputHelper output) : DirectiveTest(output, """ ::::{csv-include} test-data.csv :::: """) { protected override void AddToFileSystem(MockFileSystem fileSystem) => - fileSystem.AddFile("docs/test-data.csv", new MockFileData( - """ + fileSystem.AddFile( + "docs/test-data.csv", + new MockFileData( + """ Name,Terms "OpenAI","[Terms A](https://example.com/a)
    [Terms B](https://example.com/b)" - """)); + """ + ) + ); [Fact] public void RendersHtmlBreaksInCsvCells() => Html.Should().Contain("(output, -""" +public class CsvIncludeNotFoundTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{csv-include} missing-file.csv ::: -""") +""" +) { [Fact] public void ReportsFileNotFound() => Block!.Found.Should().BeFalse(); @@ -181,8 +208,7 @@ public void EmitsErrorForMissingFile() } } -public class CsvIncludeNoArgumentTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class CsvIncludeNoArgumentTests(ITestOutputHelper output) : DirectiveTest(output, """ :::{csv-include} ::: """) diff --git a/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs b/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs index 6add618421..462cd73c83 100644 --- a/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs @@ -13,18 +13,17 @@ namespace Elastic.Markdown.Tests.Directives; -public abstract class DirectiveTest(ITestOutputHelper output, [LanguageInjection("markdown")] string content) - : DirectiveTest(output, content) - where TDirective : DirectiveBlock +public abstract class DirectiveTest(ITestOutputHelper output, [LanguageInjection("markdown")] string content) : DirectiveTest( + output, + content +) where TDirective : DirectiveBlock { protected TDirective? Block { get; private set; } public override async ValueTask InitializeAsync() { await base.InitializeAsync(); - Block = Document - .Descendants() - .FirstOrDefault(); + Block = Document.Descendants().FirstOrDefault(); } [Fact] @@ -47,21 +46,21 @@ protected DirectiveTest(ITestOutputHelper output, [LanguageInjection("markdown") var logger = new TestLoggerFactory(output); TestingFullDocument = string.IsNullOrEmpty(content) || content.StartsWith("---", StringComparison.OrdinalIgnoreCase); - var documentContents = TestingFullDocument ? content : -// language=markdown -$""" + var documentContents = TestingFullDocument + ? content + : + // language=markdown + $""" # Test Document {content} """; - FileSystem = new MockFileSystem(new Dictionary - { - { "docs/index.md", new MockFileData(documentContents) } - }, new MockFileSystemOptions - { - CurrentDirectory = Paths.WorkingDirectoryRoot.FullName - }); + FileSystem = + new MockFileSystem( + new Dictionary { { "docs/index.md", new MockFileData(documentContents) } }, + new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName } + ); // ReSharper disable once VirtualMemberCallInConstructor // nasty but sub implementations won't use class state. AddToFileSystem(FileSystem); @@ -74,7 +73,12 @@ protected DirectiveTest(ITestOutputHelper output, [LanguageInjection("markdown") // ReSharper disable once VirtualMemberCallInConstructor var environment = GetEnvironment(); // ReSharper disable once VirtualMemberCallInConstructor - var context = new BuildContext(Collector, TestHelpers.CreateDocumentationFileSystem(FileSystem, root, GetGitCheckoutInformation()), configurationContext, environment); + var context = new BuildContext( + Collector, + TestHelpers.CreateDocumentationFileSystem(FileSystem, root, GetGitCheckoutInformation()), + configurationContext, + environment + ); var linkResolver = new TestCrossLinkResolver(); // ReSharper disable once VirtualMemberCallInConstructor Set = new DocumentationSet(context, logger, linkResolver, GetReleaseNotesResolver()); @@ -132,7 +136,9 @@ protected IReadOnlyList ReadMermaidSvgs() { var outputDir = Set.Context.OutputDirectory.FullName; return FileSystem.AllFiles - .Where(f => f.StartsWith(outputDir, StringComparison.OrdinalIgnoreCase) && f.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)) + .Where( + f => f.StartsWith(outputDir, StringComparison.OrdinalIgnoreCase) && f.EndsWith(".svg", StringComparison.OrdinalIgnoreCase) + ) .Select(f => FileSystem.File.ReadAllText(f)) .ToList(); } diff --git a/tests/Elastic.Markdown.Tests/Directives/ImageCarouselTests.cs b/tests/Elastic.Markdown.Tests/Directives/ImageCarouselTests.cs index 083d46acef..9aaf81e65a 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ImageCarouselTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ImageCarouselTests.cs @@ -9,8 +9,9 @@ namespace Elastic.Markdown.Tests.Directives; -public class ImageCarouselBlockTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ImageCarouselBlockTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{carousel} :max-height: medium @@ -35,10 +36,7 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) public void ParsesBlock() => Block.Should().NotBeNull(); [Fact] - public void ParsesCarouselProperties() - { - Block!.MaxHeight.Should().Be("medium"); - } + public void ParsesCarouselProperties() => Block!.MaxHeight.Should().Be("medium"); [Fact] public void ProcessesNestedImages() @@ -58,8 +56,9 @@ public void AllImagesFoundSoNoErrorIsEmitted() } } -public class ImageCarouselWithSmallHeightTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ImageCarouselWithSmallHeightTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{carousel} :max-height: small @@ -70,8 +69,7 @@ public class ImageCarouselWithSmallHeightTests(ITestOutputHelper output) : Direc """ ) { - protected override void AddToFileSystem(MockFileSystem fileSystem) => - fileSystem.AddFile(@"docs/img/small.png", ""); + protected override void AddToFileSystem(MockFileSystem fileSystem) => fileSystem.AddFile(@"docs/img/small.png", ""); [Fact] public void ParsesSmallMaxHeight() @@ -81,8 +79,9 @@ public void ParsesSmallMaxHeight() } } -public class ImageCarouselWithAutoHeightTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ImageCarouselWithAutoHeightTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{carousel} :max-height: none @@ -93,8 +92,7 @@ public class ImageCarouselWithAutoHeightTests(ITestOutputHelper output) : Direct """ ) { - protected override void AddToFileSystem(MockFileSystem fileSystem) => - fileSystem.AddFile(@"docs/img/auto.png", ""); + protected override void AddToFileSystem(MockFileSystem fileSystem) => fileSystem.AddFile(@"docs/img/auto.png", ""); [Fact] public void ParsesNoneMaxHeight() @@ -104,8 +102,9 @@ public void ParsesNoneMaxHeight() } } -public class ImageCarouselWithInvalidHeightTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ImageCarouselWithInvalidHeightTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{carousel} :max-height: large @@ -116,16 +115,14 @@ public class ImageCarouselWithInvalidHeightTests(ITestOutputHelper output) : Dir """ ) { - protected override void AddToFileSystem(MockFileSystem fileSystem) => - fileSystem.AddFile(@"docs/img/invalid.png", ""); + protected override void AddToFileSystem(MockFileSystem fileSystem) => fileSystem.AddFile(@"docs/img/invalid.png", ""); [Fact] public void WarnsOnInvalidMaxHeight() { Block!.MaxHeight.Should().Be("large"); - Collector.Diagnostics.Should().HaveCount(1) - .And.OnlyContain(d => d.Severity == Severity.Warning); + Collector.Diagnostics.Should().HaveCount(1).And.OnlyContain(d => d.Severity == Severity.Warning); var warning = Collector.Diagnostics.First(); warning.Message.Should().Contain("Invalid max-height value 'large'"); @@ -133,8 +130,9 @@ public void WarnsOnInvalidMaxHeight() } } -public class ImageCarouselWithoutImagesTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ImageCarouselWithoutImagesTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{carousel} ::: """ @@ -145,16 +143,16 @@ public void EmitsErrorForEmptyCarousel() { Block!.Images.Should().BeEmpty(); - Collector.Diagnostics.Should().HaveCount(1) - .And.OnlyContain(d => d.Severity == Severity.Error); + Collector.Diagnostics.Should().HaveCount(1).And.OnlyContain(d => d.Severity == Severity.Error); var error = Collector.Diagnostics.First(); error.Message.Should().Be("carousel directive requires nested image directives"); } } -public class ImageCarouselMinimalTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ImageCarouselMinimalTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{carousel} ```{image} img/minimal.png @@ -164,8 +162,7 @@ public class ImageCarouselMinimalTests(ITestOutputHelper output) : DirectiveTest """ ) { - protected override void AddToFileSystem(MockFileSystem fileSystem) => - fileSystem.AddFile(@"docs/img/minimal.png", ""); + protected override void AddToFileSystem(MockFileSystem fileSystem) => fileSystem.AddFile(@"docs/img/minimal.png", ""); [Fact] public void ParsesMinimalCarousel() @@ -177,8 +174,9 @@ public void ParsesMinimalCarousel() } } -public class ImageCarouselWithMissingImageTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ImageCarouselWithMissingImageTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{carousel} ```{image} img/missing.png @@ -192,18 +190,16 @@ public class ImageCarouselWithMissingImageTests(ITestOutputHelper output) : Dire """ ) { - protected override void AddToFileSystem(MockFileSystem fileSystem) => - fileSystem.AddFile(@"docs/img/exists.png", ""); + protected override void AddToFileSystem(MockFileSystem fileSystem) => fileSystem.AddFile(@"docs/img/exists.png", ""); [Fact] public void HandlesPartiallyMissingImages() { Block!.Images.Should().HaveCount(2); Block!.Images[0].Found.Should().BeFalse(); // missing.png - Block!.Images[1].Found.Should().BeTrue(); // exists.png + Block!.Images[1].Found.Should().BeTrue(); // exists.png // Should have diagnostics for the missing image - Collector.Diagnostics.Should().HaveCount(1) - .And.OnlyContain(d => d.Severity == Severity.Error); + Collector.Diagnostics.Should().HaveCount(1).And.OnlyContain(d => d.Severity == Severity.Error); } } diff --git a/tests/Elastic.Markdown.Tests/Directives/ImageTests.cs b/tests/Elastic.Markdown.Tests/Directives/ImageTests.cs index e31bcd6479..4d81252e66 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ImageTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ImageTests.cs @@ -9,8 +9,9 @@ namespace Elastic.Markdown.Tests.Directives; -public class ImageBlockTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ImageBlockTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{image} img/observability.png :alt: Elasticsearch :width: 250px @@ -19,8 +20,7 @@ public class ImageBlockTests(ITestOutputHelper output) : DirectiveTest - fileSystem.AddFile(@"docs/img/observability.png", ""); + protected override void AddToFileSystem(MockFileSystem fileSystem) => fileSystem.AddFile(@"docs/img/observability.png", ""); [Fact] public void ParsesBlock() => Block.Should().NotBeNull(); @@ -42,8 +42,9 @@ public void ImageIsFoundSoNoErrorIsEmitted() } } -public class AllowedExternalHostTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class AllowedExternalHostTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{image} https://images.contentstack.io/v3/assets/bltefdd0b53724fa2ce/blt/example.gif :alt: An animated screenshot hosted on the Elastic Contentstack CDN ::: @@ -61,8 +62,9 @@ public void AllowedHostDoesNotWarn() } } -public class FigureTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class FigureTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{figure} https://github.com/rowanc1/pics/blob/main/sunset.png?raw=true :label: myFigure :alt: Sunset at the beach @@ -81,7 +83,6 @@ public void WarnsOnExternalUri() { Block!.Found.Should().BeTrue(); - Collector.Diagnostics.Should().HaveCount(1) - .And.OnlyContain(d => d.Severity == Severity.Warning); + Collector.Diagnostics.Should().HaveCount(1).And.OnlyContain(d => d.Severity == Severity.Warning); } } diff --git a/tests/Elastic.Markdown.Tests/Directives/ListSubPagesTests.cs b/tests/Elastic.Markdown.Tests/Directives/ListSubPagesTests.cs index 7495e1a108..c15ad1d086 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ListSubPagesTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ListSubPagesTests.cs @@ -8,8 +8,7 @@ namespace Elastic.Markdown.Tests.Directives; -public class ListSubPagesTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ListSubPagesTests(ITestOutputHelper output) : DirectiveTest(output, """ :::{list-sub-pages} ::: """) @@ -34,12 +33,8 @@ public void ResolvesSubPagesFromNavigation() } [Fact] - public void SubPagesContainTitlesAndUrls() - { - Block!.SubPages.Should().OnlyContain(p => - !string.IsNullOrEmpty(p.Title) && - !string.IsNullOrEmpty(p.Url)); - } + public void SubPagesContainTitlesAndUrls() => + Block!.SubPages.Should().OnlyContain(p => !string.IsNullOrEmpty(p.Title) && !string.IsNullOrEmpty(p.Url)); [Fact] public void RendersListWithLinks() @@ -50,16 +45,17 @@ public void RendersListWithLinks() } } -public class ListSubPagesWithDescriptionsTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ListSubPagesWithDescriptionsTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{list-sub-pages} ::: -""") +""" +) { protected override void AddToFileSystem(MockFileSystem fileSystem) { - fileSystem.AddFile("docs/page1.md", new MockFileData( -""" + fileSystem.AddFile("docs/page1.md", new MockFileData(""" --- description: First page description --- @@ -79,17 +75,16 @@ public void IncludesDescriptionWhenPresent() } [Fact] - public void RendersDescriptionInOutput() - { - Html.Should().Contain("First page description"); - } + public void RendersDescriptionInOutput() => Html.Should().Contain("First page description"); } -public class ListSubPagesWithFolderSiblingTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class ListSubPagesWithFolderSiblingTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{list-sub-pages} ::: -""") +""" +) { protected override void AddToFileSystem(MockFileSystem fileSystem) { diff --git a/tests/Elastic.Markdown.Tests/Directives/MathBlockTests.cs b/tests/Elastic.Markdown.Tests/Directives/MathBlockTests.cs index 9e518174c3..c42dccd468 100644 --- a/tests/Elastic.Markdown.Tests/Directives/MathBlockTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/MathBlockTests.cs @@ -8,13 +8,11 @@ namespace Elastic.Markdown.Tests.Directives; -public class MathBlockTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class MathBlockTests(ITestOutputHelper output) : DirectiveTest(output, """ :::{math} E = mc^2 ::: -""" -) +""") { [Fact] public void ParsesMathBlock() => Block.Should().NotBeNull(); @@ -32,8 +30,9 @@ public class MathBlockTests(ITestOutputHelper output) : DirectiveTest public void RendersMathSpan() => Html.Should().Contain("E = mc^2"); } -public class MathBlockDisplayMathTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class MathBlockDisplayMathTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{math} \[ \int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi} @@ -55,8 +54,9 @@ public class MathBlockDisplayMathTests(ITestOutputHelper output) : DirectiveTest public void RendersDisplayMathDiv() => Html.Should().Contain("
    "); } -public class MathBlockWithLabelTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class MathBlockWithLabelTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{math} :label: einstein-mass-energy E = mc^2 @@ -74,29 +74,26 @@ public class MathBlockWithLabelTests(ITestOutputHelper output) : DirectiveTest Html.Should().Contain("id=\"einstein-mass-energy\""); } -public class MathBlockEmptyTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class MathBlockEmptyTests(ITestOutputHelper output) : DirectiveTest(output, """ :::{math} ::: -""" -) +""") { [Fact] - public void EmptyContentGeneratesError() => - Collector.Errors.Should().Be(1); + public void EmptyContentGeneratesError() => Collector.Errors.Should().Be(1); [Fact] public void EmitsErrorForEmptyContent() { Collector.Diagnostics.Should().NotBeNullOrEmpty().And.HaveCount(1); Collector.Diagnostics.Should().OnlyContain(d => d.Severity == Severity.Error); - Collector.Diagnostics.Should() - .OnlyContain(d => d.Message.StartsWith("Math directive requires content.")); + Collector.Diagnostics.Should().OnlyContain(d => d.Message.StartsWith("Math directive requires content.")); } } -public class MathBlockComplexExpressionTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class MathBlockComplexExpressionTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{math} \begin{align} \frac{\partial f}{\partial x} &= \lim_{h \to 0} \frac{f(x+h) - f(x)}{h} \\ diff --git a/tests/Elastic.Markdown.Tests/Directives/MermaidTests.cs b/tests/Elastic.Markdown.Tests/Directives/MermaidTests.cs index f14139014c..c85777f615 100644 --- a/tests/Elastic.Markdown.Tests/Directives/MermaidTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/MermaidTests.cs @@ -8,8 +8,9 @@ namespace Elastic.Markdown.Tests.Directives; -public class MermaidFlowchartTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class MermaidFlowchartTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ```mermaid flowchart LR A[Start] --> B[Process] @@ -45,8 +46,9 @@ public void SvgContainsNodeLabels() } } -public class MermaidSequenceTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class MermaidSequenceTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ```mermaid sequenceDiagram participant A as Alice @@ -77,8 +79,9 @@ public void SvgContainsParticipantLabels() } } -public class MermaidStateDiagramTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class MermaidStateDiagramTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ```mermaid stateDiagram-v2 [*] --> Idle @@ -110,8 +113,9 @@ public void SvgContainsStateLabels() } } -public class MermaidClassDiagramTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class MermaidClassDiagramTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ```mermaid classDiagram Animal <|-- Duck @@ -142,8 +146,9 @@ public void SvgContainsClassLabels() } } -public class MermaidErDiagramTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class MermaidErDiagramTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ```mermaid erDiagram CUSTOMER ||--o{ ORDER : places @@ -175,8 +180,9 @@ public void SvgContainsEntityLabels() // classDef/style directives are stripped by strict styling (Strip mode) — diagram still renders as SVG, // each stripped item fires OnStripped as a hint. -public class MermaidStyledFlowchartTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class MermaidStyledFlowchartTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ```mermaid flowchart LR A[Start] --> B[Process] @@ -198,8 +204,9 @@ class A elasticBlue } // Allowlisted semantic classes render correctly with site palette colors baked into SVG. -public class MermaidStrictClassTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class MermaidStrictClassTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ```mermaid flowchart LR A[Start]:::warning --> B[End] @@ -221,8 +228,9 @@ flowchart LR } // DataPalette: pie chart SVG should use our theme palette, not the Tableau CB10 default. -public class MermaidPieDataPaletteTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class MermaidPieDataPaletteTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ```mermaid pie "Blue" : 40 diff --git a/tests/Elastic.Markdown.Tests/Directives/StorybookTests.cs b/tests/Elastic.Markdown.Tests/Directives/StorybookTests.cs index 4e380ca6ad..57b027c273 100644 --- a/tests/Elastic.Markdown.Tests/Directives/StorybookTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/StorybookTests.cs @@ -15,15 +15,14 @@ public abstract class StorybookRegistryTest(ITestOutputHelper output, string con protected override void AddToFileSystem(MockFileSystem fileSystem) => fileSystem.AddFile("docs/docs_registry.json", new MockFileData(RegistryJson)); - protected override string? GetDocsetExtraYaml() => -""" + protected override string? GetDocsetExtraYaml() => """ storybook: registry: docs_registry.json """; private const string RegistryJson = - /*lang=json,strict*/ - """ + /*lang=json,strict*/ + """ { "schemaVersion": 1, "producer": "kibana-storybook", @@ -78,8 +77,9 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) => """; } -public class StorybookInlineIdTests(ITestOutputHelper output) : StorybookRegistryTest(output, -""" +public class StorybookInlineIdTests(ITestOutputHelper output) : StorybookRegistryTest( + output, + """ :::{storybook} :id: kibana:shared_ux:ai-components-aibutton--default :title: AI Button / Default story @@ -95,7 +95,9 @@ public void ResolvesStory() Block.DocsId.Should().Be("ai-components-aibutton--default"); Block.StoryId.Should().Be("ai-components-aibutton--default"); Block.InlineEntry.Should().Be("http://127.0.0.1:6007/storybook-docs/shared_ux/registry.js"); - Block.StoryUrl.Should().Be("http://127.0.0.1:6007/storybook/shared_ux/iframe.html?id=ai-components-aibutton--default&viewMode=story"); + Block.StoryUrl + .Should() + .Be("http://127.0.0.1:6007/storybook/shared_ux/iframe.html?id=ai-components-aibutton--default&viewMode=story"); Block.Height.Should().Be(360); } @@ -116,18 +118,16 @@ internal sealed class TestEnvironmentVariables : IEnvironmentVariables { private readonly Dictionary _variables = [with(StringComparer.Ordinal)]; - public string? this[string name] - { - set => _variables[name] = value; - } + public string? this[string name] { set => _variables[name] = value; } public string? GetEnvironmentVariable(string name) => _variables.GetValueOrDefault(name); public bool IsRunningOnCI => false; } -public class StorybookInterpolatedRegistryTests(ITestOutputHelper output) : StorybookRegistryTest(output, -""" +public class StorybookInterpolatedRegistryTests(ITestOutputHelper output) : StorybookRegistryTest( + output, + """ :::{storybook} :id: kibana:shared_ux:ai-components-aibutton--default ::: @@ -136,19 +136,18 @@ public class StorybookInterpolatedRegistryTests(ITestOutputHelper output) : Stor { protected override IEnvironmentVariables? GetEnvironment() => new TestEnvironmentVariables(); - protected override string? GetDocsetExtraYaml() => -""" + protected override string? GetDocsetExtraYaml() => """ storybook: registry: ${KIBANA_STORYBOOK_REGISTRY:-docs_registry.json} """; [Fact] - public void ResolvesDefaultWhenEnvironmentVariableUnset() => - Block!.StoryId.Should().Be("ai-components-aibutton--default"); + public void ResolvesDefaultWhenEnvironmentVariableUnset() => Block!.StoryId.Should().Be("ai-components-aibutton--default"); } -public class StorybookDisallowedRegistryVariableTests(ITestOutputHelper output) : StorybookRegistryTest(output, -""" +public class StorybookDisallowedRegistryVariableTests(ITestOutputHelper output) : StorybookRegistryTest( + output, + """ :::{storybook} :id: kibana:shared_ux:ai-components-aibutton--default ::: @@ -159,21 +158,21 @@ public class StorybookDisallowedRegistryVariableTests(ITestOutputHelper output) protected override IEnvironmentVariables? GetEnvironment() => new TestEnvironmentVariables { ["AWS_SECRET_ACCESS_KEY"] = "super-secret" }; - protected override string? GetDocsetExtraYaml() => -""" + protected override string? GetDocsetExtraYaml() => """ storybook: registry: ${AWS_SECRET_ACCESS_KEY:-docs_registry.json} """; [Fact] public void WarnsAndLeavesExpressionLiteral() => - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Warning - && d.Message.Contains("not allow-listed for interpolation")); + Collector.Diagnostics + .Should() + .Contain(d => d.Severity == Severity.Warning && d.Message.Contains("not allow-listed for interpolation")); } -public class StorybookStructuredReferenceTests(ITestOutputHelper output) : StorybookRegistryTest(output, -""" +public class StorybookStructuredReferenceTests(ITestOutputHelper output) : StorybookRegistryTest( + output, + """ :::{storybook} :project: kibana :storybook: shared_ux @@ -184,12 +183,12 @@ public class StorybookStructuredReferenceTests(ITestOutputHelper output) : Story ) { [Fact] - public void ResolvesComponentAndStory() => - Block!.StoryId.Should().Be("ai-components-aibutton--default"); + public void ResolvesComponentAndStory() => Block!.StoryId.Should().Be("ai-components-aibutton--default"); } -public class StorybookStructuredReferenceWrongStorybookTests(ITestOutputHelper output) : StorybookRegistryTest(output, -""" +public class StorybookStructuredReferenceWrongStorybookTests(ITestOutputHelper output) : StorybookRegistryTest( + output, + """ :::{storybook} :project: kibana :storybook: content_management @@ -200,11 +199,14 @@ public class StorybookStructuredReferenceWrongStorybookTests(ITestOutputHelper o { [Fact] public void DoesNotFallbackToAnotherStorybook() => - Collector.Diagnostics.Should().Contain(d => d.Message.Contains("does not contain id 'kibana:content_management:ai-components-aibutton--default'")); + Collector.Diagnostics + .Should() + .Contain(d => d.Message.Contains("does not contain id 'kibana:content_management:ai-components-aibutton--default'")); } -public class StorybookBareIdTests(ITestOutputHelper output) : StorybookRegistryTest(output, -""" +public class StorybookBareIdTests(ITestOutputHelper output) : StorybookRegistryTest( + output, + """ :::{storybook} :id: ai-components-aibutton--default ::: @@ -212,12 +214,12 @@ public class StorybookBareIdTests(ITestOutputHelper output) : StorybookRegistryT ) { [Fact] - public void ResolvesFromConfiguredRegistry() => - Block!.StoryId.Should().Be("ai-components-aibutton--default"); + public void ResolvesFromConfiguredRegistry() => Block!.StoryId.Should().Be("ai-components-aibutton--default"); } -public class StorybookIframeTests(ITestOutputHelper output) : StorybookRegistryTest(output, -""" +public class StorybookIframeTests(ITestOutputHelper output) : StorybookRegistryTest( + output, + """ :::{storybook} :id: kibana:shared_ux:components-callout--info ::: @@ -229,12 +231,15 @@ public void RendersIframeFallback() { Block!.HasInlineStory.Should().BeFalse(); Html.Should().Contain(" - Html.Should().Contain("Supporting details for this story."); + public void RendersBodyContent() => Html.Should().Contain("Supporting details for this story."); } -public class StorybookInvalidHeightTests(ITestOutputHelper output) : StorybookRegistryTest(output, -""" +public class StorybookInvalidHeightTests(ITestOutputHelper output) : StorybookRegistryTest( + output, + """ :::{storybook} :id: kibana:shared_ux:components-callout--info :height: tall @@ -260,15 +265,16 @@ public class StorybookInvalidHeightTests(ITestOutputHelper output) : StorybookRe public void WarnsAndFallsBackToDefaultHeight() { Block!.Height.Should().Be(400); - Collector.Diagnostics.Should().ContainSingle(d => - d.Severity == Severity.Warning - && d.Message.Contains(":height: must be a positive integer")); + Collector.Diagnostics + .Should() + .ContainSingle(d => d.Severity == Severity.Warning && d.Message.Contains(":height: must be a positive integer")); Html.Should().Contain("height:400px"); } } -public class StorybookMissingRegistryTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class StorybookMissingRegistryTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{storybook} :id: kibana:shared_ux:ai-components-aibutton--default ::: @@ -276,24 +282,21 @@ public class StorybookMissingRegistryTests(ITestOutputHelper output) : Directive ) { [Fact] - public void EmitsError() => - Collector.Diagnostics.Should().Contain(d => d.Message.Contains("requires docset.yml storybook.registry")); + public void EmitsError() => Collector.Diagnostics.Should().Contain(d => d.Message.Contains("requires docset.yml storybook.registry")); } -public class StorybookMissingIdTests(ITestOutputHelper output) : StorybookRegistryTest(output, -""" +public class StorybookMissingIdTests(ITestOutputHelper output) : StorybookRegistryTest(output, """ :::{storybook} ::: -""" -) +""") { [Fact] - public void EmitsError() => - Collector.Diagnostics.Should().Contain(d => d.Message.Contains("requires :id: or :project:")); + public void EmitsError() => Collector.Diagnostics.Should().Contain(d => d.Message.Contains("requires :id: or :project:")); } -public class StorybookPositionalArgumentWarningTests(ITestOutputHelper output) : StorybookRegistryTest(output, -""" +public class StorybookPositionalArgumentWarningTests(ITestOutputHelper output) : StorybookRegistryTest( + output, + """ :::{storybook} /storybook/ignored :id: kibana:shared_ux:components-callout--info ::: @@ -302,7 +305,7 @@ public class StorybookPositionalArgumentWarningTests(ITestOutputHelper output) : { [Fact] public void EmitsWarning() => - Collector.Diagnostics.Should().ContainSingle(d => - d.Severity == Severity.Warning - && d.Message.Contains("ignores positional arguments")); + Collector.Diagnostics + .Should() + .ContainSingle(d => d.Severity == Severity.Warning && d.Message.Contains("ignores positional arguments")); } diff --git a/tests/Elastic.Markdown.Tests/Directives/TabTests.cs b/tests/Elastic.Markdown.Tests/Directives/TabTests.cs index ee6b520123..714ff3426f 100644 --- a/tests/Elastic.Markdown.Tests/Directives/TabTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/TabTests.cs @@ -7,8 +7,9 @@ namespace Elastic.Markdown.Tests.Directives; -public class TabTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class TabTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::::{tab-set} ::::{tab-item} Admonition @@ -52,8 +53,9 @@ public void ParsesTabItems() } } -public class MultipleTabTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class MultipleTabTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::::{tab-set} ::::{tab-item} Admonition :::{tip} @@ -92,8 +94,9 @@ public void ParsesMultipleTabSets() } } -public class GroupTabTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class GroupTabTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ::::{tab-set} :group: languages :::{tab-item} Java diff --git a/tests/Elastic.Markdown.Tests/Directives/TableDirectiveTests.cs b/tests/Elastic.Markdown.Tests/Directives/TableDirectiveTests.cs index 4e0340bfe7..f70ce5488b 100644 --- a/tests/Elastic.Markdown.Tests/Directives/TableDirectiveTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/TableDirectiveTests.cs @@ -8,14 +8,16 @@ namespace Elastic.Markdown.Tests.Directives; -public class TableDirectiveBasicTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class TableDirectiveBasicTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{table} | head a | head b | | --- | --- | | a | b | ::: -""") +""" +) { [Fact] public void ParsesTableDirectiveBlock() => Block.Should().NotBeNull(); @@ -33,8 +35,9 @@ public void RendersTableInOutput() } } -public class TableDirectiveWithWidthsTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class TableDirectiveWithWidthsTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{table} :widths: 4-8 @@ -42,7 +45,8 @@ public class TableDirectiveWithWidthsTests(ITestOutputHelper output) : Directive | --- | --- | | a | b | ::: -""") +""" +) { [Fact] public void ParsesWidthsOption() @@ -62,8 +66,9 @@ public void RendersColgroupWithWidths() } } -public class TableDirectiveDescriptionPresetTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class TableDirectiveDescriptionPresetTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{table} :widths: description @@ -71,7 +76,8 @@ public class TableDirectiveDescriptionPresetTests(ITestOutputHelper output) : Di | --- | --- | | foo | A thing | ::: -""") +""" +) { [Fact] public void MapsDescriptionTo4_8() @@ -85,8 +91,9 @@ public void MapsDescriptionTo4_8() public void RendersColgroup() => Html.Should().Contain("colgroup"); } -public class TableDirectiveAutoPresetTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class TableDirectiveAutoPresetTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{table} :widths: auto @@ -94,7 +101,8 @@ public class TableDirectiveAutoPresetTests(ITestOutputHelper output) : Directive | --- | --- | --- | | 1 | 2 | 3 | ::: -""") +""" +) { [Fact] public void HasNoColumnWidths() => Block!.ColumnWidths.Should().BeEmpty(); @@ -103,8 +111,9 @@ public class TableDirectiveAutoPresetTests(ITestOutputHelper output) : Directive public void DoesNotInjectColgroup() => Html.Should().NotContain("colgroup"); } -public class TableDirectiveMatrixTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class TableDirectiveMatrixTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{table} :matrix: @@ -112,7 +121,8 @@ public class TableDirectiveMatrixTests(ITestOutputHelper output) : DirectiveTest | --- | --- | | a | b | ::: -""") +""" +) { [Fact] public void ParsesMatrixOption() => Block!.Matrix.Should().BeTrue(); @@ -121,21 +131,24 @@ public class TableDirectiveMatrixTests(ITestOutputHelper output) : DirectiveTest public void RendersMatrixClass() => Html.Should().Contain("table-wrapper table-matrix"); } -public class TableDirectiveWithoutMatrixTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class TableDirectiveWithoutMatrixTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{table} | head a | head b | | --- | --- | | a | b | ::: -""") +""" +) { [Fact] public void DoesNotRenderMatrixClass() => Html.Should().NotContain("table-matrix"); } -public class TableDirectiveWidthCountMismatchTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class TableDirectiveWidthCountMismatchTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{table} :widths: 4-4-4 @@ -143,14 +156,17 @@ public class TableDirectiveWidthCountMismatchTests(ITestOutputHelper output) : D | --- | --- | | a | b | ::: -""") +""" +) { [Fact] - public void EmitsError() => Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("does not match")); + public void EmitsError() => + Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("does not match")); } -public class TableDirectiveWidthsSumErrorTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class TableDirectiveWidthsSumErrorTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{table} :widths: 4-4 @@ -158,27 +174,33 @@ public class TableDirectiveWidthsSumErrorTests(ITestOutputHelper output) : Direc | --- | --- | | a | b | ::: -""") +""" +) { [Fact] - public void EmitsError() => Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("sum to 12")); + public void EmitsError() => + Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("sum to 12")); } -public class TableDirectiveNoTableTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class TableDirectiveNoTableTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{table} :widths: 4-8 Some text, no table. ::: -""") +""" +) { [Fact] - public void EmitsError() => Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("pipe table")); + public void EmitsError() => + Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("pipe table")); } -public class TableDirectiveInvalidWidthsTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class TableDirectiveInvalidWidthsTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{table} :widths: foo @@ -186,15 +208,17 @@ public class TableDirectiveInvalidWidthsTests(ITestOutputHelper output) : Direct | --- | --- | | a | b | ::: -""") +""" +) { [Fact] public void EmitsErrorForInvalidPreset() => Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("Invalid widths value")); } -public class TableDirectiveOutOfRangeWidthsTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class TableDirectiveOutOfRangeWidthsTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{table} :widths: 0-12 @@ -202,15 +226,17 @@ public class TableDirectiveOutOfRangeWidthsTests(ITestOutputHelper output) : Dir | --- | --- | | a | b | ::: -""") +""" +) { [Fact] public void EmitsErrorForOutOfRangeUnit() => Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("Invalid widths value")); } -public class TableDirectiveMultipleTablesTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class TableDirectiveMultipleTablesTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{table} :widths: 4-8 @@ -222,7 +248,8 @@ public class TableDirectiveMultipleTablesTests(ITestOutputHelper output) : Direc | --- | --- | | 3 | 4 | ::: -""") +""" +) { [Fact] public void EmitsErrorForMultipleTables() => diff --git a/tests/Elastic.Markdown.Tests/Directives/UnsupportedTests.cs b/tests/Elastic.Markdown.Tests/Directives/UnsupportedTests.cs index 9c72f42280..cd6c3165f8 100644 --- a/tests/Elastic.Markdown.Tests/Directives/UnsupportedTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/UnsupportedTests.cs @@ -8,9 +8,9 @@ namespace Elastic.Markdown.Tests.Directives; -public abstract class UnsupportedDirectiveTests(ITestOutputHelper output, string directive) - : DirectiveTest(output, -$$""" +public abstract class UnsupportedDirectiveTests(ITestOutputHelper output, string directive) : DirectiveTest( + output, + $$""" Content before bad directive ```{{{directive}}} @@ -34,8 +34,7 @@ public void EmitsUnsupportedWarnings() { Collector.Diagnostics.Should().NotBeNullOrEmpty().And.HaveCount(1); Collector.Diagnostics.Should().OnlyContain(d => d.Severity == Severity.Warning); - Collector.Diagnostics.Should() - .OnlyContain(d => d.Message.StartsWith($"Directive block '{directive}' is unsupported.")); + Collector.Diagnostics.Should().OnlyContain(d => d.Message.StartsWith($"Directive block '{directive}' is unsupported.")); } } diff --git a/tests/Elastic.Markdown.Tests/Directives/VersionTests.cs b/tests/Elastic.Markdown.Tests/Directives/VersionTests.cs index 4b41e3e982..1c2fc858b0 100644 --- a/tests/Elastic.Markdown.Tests/Directives/VersionTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/VersionTests.cs @@ -9,8 +9,9 @@ namespace Elastic.Markdown.Tests.Directives; -public abstract class VersionTests(ITestOutputHelper output, string directive) : DirectiveTest(output, -$$""" +public abstract class VersionTests(ITestOutputHelper output, string directive) : DirectiveTest( + output, + $$""" :::{{{directive}}} 1.0.1-beta1 more information Version brief summary ::: @@ -50,8 +51,9 @@ public class VersionDeprectatedTests(ITestOutputHelper output) : VersionTests(ou public void SetsTitle() => Block!.Title.Should().Be("Deprecated (1.0.1-beta1): more information"); } -public abstract class VersionValidationTests(ITestOutputHelper output, string version) : DirectiveTest(output, -$$""" +public abstract class VersionValidationTests(ITestOutputHelper output, string version) : DirectiveTest( + output, + $$""" :::{versionchanged} {{version}} more information Version brief summary ::: @@ -71,13 +73,13 @@ public class SimpleVersion(ITestOutputHelper output) : VersionValidationTests(ou public class MajorVersionOnly(ITestOutputHelper output) : VersionValidationTests(output, "8") { [Fact] - public void HasError() => Collector.Diagnostics.Should().HaveCount(1) - .And.Contain(d => d.Message.Contains("'8' is not a valid version")); + public void HasError() => + Collector.Diagnostics.Should().HaveCount(1).And.Contain(d => d.Message.Contains("'8' is not a valid version")); } public class BranchVersion(ITestOutputHelper output) : VersionValidationTests(output, "8.x") { [Fact] - public void HasError() => Collector.Diagnostics.Should().HaveCount(1) - .And.Contain(d => d.Message.Contains("'8.x' is not a valid version")); + public void HasError() => + Collector.Diagnostics.Should().HaveCount(1).And.Contain(d => d.Message.Contains("'8.x' is not a valid version")); } diff --git a/tests/Elastic.Markdown.Tests/DocSet/BreadCrumbTests.cs b/tests/Elastic.Markdown.Tests/DocSet/BreadCrumbTests.cs index 839d2273ce..ad4b5026cc 100644 --- a/tests/Elastic.Markdown.Tests/DocSet/BreadCrumbTests.cs +++ b/tests/Elastic.Markdown.Tests/DocSet/BreadCrumbTests.cs @@ -19,12 +19,14 @@ public void CanQueryParentsSuccessfully() var allKeys = crossLinks.Keys.ToList(); var lookup = Path.Join("nested", "index.md"); - var doc = Generator.DocumentationSet.MarkdownFiles + var doc = Generator.DocumentationSet + .MarkdownFiles .FirstOrDefault(f => f.SourceFile.FullName.EndsWith(lookup, StringComparison.OrdinalIgnoreCase)); doc.Should().NotBeNull(); - var deeplyNestedDoc = Generator.DocumentationSet.MarkdownFiles + var deeplyNestedDoc = Generator.DocumentationSet + .MarkdownFiles .FirstOrDefault(f => f.RelativePath.OptionalWindowsReplace().EndsWith("deeply-nested/foo.md", StringComparison.Ordinal)); deeplyNestedDoc.Should().NotBeNull(); @@ -38,6 +40,5 @@ public void CanQueryParentsSuccessfully() var parents = navigationTraversable.GetParentsOfMarkdownFile(doc); parents.Should().HaveCount(1); - } } diff --git a/tests/Elastic.Markdown.Tests/DocSet/NavigationTests.cs b/tests/Elastic.Markdown.Tests/DocSet/NavigationTests.cs index 1f2c356f25..a17f5cd9c0 100644 --- a/tests/Elastic.Markdown.Tests/DocSet/NavigationTests.cs +++ b/tests/Elastic.Markdown.Tests/DocSet/NavigationTests.cs @@ -9,20 +9,24 @@ namespace Elastic.Markdown.Tests.DocSet; public class NavigationTests(ITestOutputHelper output) : NavigationTestsBase(output) { [Fact] - public void ParsesATableOfContents() => - Set.Navigation.Should().NotBeNull(); + public void ParsesATableOfContents() => Set.Navigation.Should().NotBeNull(); [Fact] public void ParsesRedirects() { Configuration.Should().NotBeNull(); - Configuration.Redirects.Should() + Configuration.Redirects + .Should() .NotBeNullOrEmpty() - .And.ContainKey("redirects/first-page-old.md") - .And.ContainKey("redirects/second-page-old.md") - .And.ContainKey("redirects/4th-page.md") - .And.ContainKey("redirects/third-page.md"); + .And + .ContainKey("redirects/first-page-old.md") + .And + .ContainKey("redirects/second-page-old.md") + .And + .ContainKey("redirects/4th-page.md") + .And + .ContainKey("redirects/third-page.md"); var redirect1 = Configuration.Redirects["redirects/first-page-old.md"]; redirect1.To.Should().Be("redirects/second-page.md"); diff --git a/tests/Elastic.Markdown.Tests/DocSet/NavigationTestsBase.cs b/tests/Elastic.Markdown.Tests/DocSet/NavigationTestsBase.cs index 5331eb018e..1b1215ebfc 100644 --- a/tests/Elastic.Markdown.Tests/DocSet/NavigationTestsBase.cs +++ b/tests/Elastic.Markdown.Tests/DocSet/NavigationTestsBase.cs @@ -18,20 +18,13 @@ public class NavigationTestsBase : IAsyncLifetime protected NavigationTestsBase(ITestOutputHelper output) { LoggerFactory = new TestLoggerFactory(output); - var mockWriteFs = new MockFileSystem(new MockFileSystemOptions - { - CurrentDirectory = Paths.WorkingDirectoryRoot.FullName - }); + var mockWriteFs = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); var docsTestsPath = Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs-tests"); var invocation = new System.IO.Abstractions.FileSystem().DirectoryInfo.New(docsTestsPath); FileSystem = DocumentationFileSystem.Resolve(invocation, new DocumentationScopeOptions { InnerWrite = mockWriteFs }); var collector = new TestDiagnosticsCollector(output); var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem.Read); - var context = new BuildContext(collector, FileSystem, configurationContext) - { - Force = false, - UrlPathPrefix = null - }; + var context = new BuildContext(collector, FileSystem, configurationContext) { Force = false, UrlPathPrefix = null }; var linkResolver = new TestCrossLinkResolver(); Set = new DocumentationSet(context, LoggerFactory, linkResolver); diff --git a/tests/Elastic.Markdown.Tests/DocSet/NestedTocTests.cs b/tests/Elastic.Markdown.Tests/DocSet/NestedTocTests.cs index 2a32c3350b..b6e7751326 100644 --- a/tests/Elastic.Markdown.Tests/DocSet/NestedTocTests.cs +++ b/tests/Elastic.Markdown.Tests/DocSet/NestedTocTests.cs @@ -22,8 +22,7 @@ public void InjectsNestedTocsIntoDocumentationSet() doc.Should().NotBeNull(); INavigationTraversable navigationTraversable = Generator.DocumentationSet; navigationTraversable.GetNavigationFor(doc).Should().NotBeNull(); - var nav = navigationTraversable.GetNavigationFor(doc) - ?? throw new Exception($"Could not find nav item for {doc.CrossLink}"); + var nav = navigationTraversable.GetNavigationFor(doc) ?? throw new Exception($"Could not find nav item for {doc.CrossLink}"); nav.Should().BeOfType>(); var parent = nav.Parent; @@ -38,6 +37,5 @@ public void InjectsNestedTocsIntoDocumentationSet() var fileNav = index as FileNavigationLeaf; fileNav.Should().NotBeNull(); fileNav.Model.RelativePath.OptionalWindowsReplace().Should().Be("index.md"); - } } diff --git a/tests/Elastic.Markdown.Tests/DocSet/ReportIssueUrlTests.cs b/tests/Elastic.Markdown.Tests/DocSet/ReportIssueUrlTests.cs index 0285d0ce59..e124d329d0 100644 --- a/tests/Elastic.Markdown.Tests/DocSet/ReportIssueUrlTests.cs +++ b/tests/Elastic.Markdown.Tests/DocSet/ReportIssueUrlTests.cs @@ -32,10 +32,7 @@ public class ReportIssueUrlTests : IAsyncLifetime public ReportIssueUrlTests(ITestOutputHelper output) { var loggerFactory = new TestLoggerFactory(output); - var mockWriteFs = new MockFileSystem(new MockFileSystemOptions - { - CurrentDirectory = Paths.WorkingDirectoryRoot.FullName - }); + var mockWriteFs = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); var invocation = new System.IO.Abstractions.FileSystem().DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); var fs = DocumentationFileSystem.Resolve(invocation, new DocumentationScopeOptions { InnerWrite = mockWriteFs }); var collector = new TestDiagnosticsCollector(output); @@ -91,8 +88,7 @@ public void BreadcrumbUrl_WithUrlPathPrefix_DoesNotDuplicatePrefix() // Item = new Uri(CanonicalBaseUrl ?? localhost, parent.Url).ToString() // Same bug would have applied here if the old JoinUrl call had been used. INavigationTraversable traversable = Set; - var nestedFile = Set.MarkdownFiles - .First(f => traversable.GetParentsOfMarkdownFile(f).Length > 0); + var nestedFile = Set.MarkdownFiles.First(f => traversable.GetParentsOfMarkdownFile(f).Length > 0); var parents = traversable.GetParentsOfMarkdownFile(nestedFile); parents.Should().NotBeEmpty(); diff --git a/tests/Elastic.Markdown.Tests/DocSet/RepositoryLinksTests.cs b/tests/Elastic.Markdown.Tests/DocSet/RepositoryLinksTests.cs index 823e27ddc0..0f41709e56 100644 --- a/tests/Elastic.Markdown.Tests/DocSet/RepositoryLinksTests.cs +++ b/tests/Elastic.Markdown.Tests/DocSet/RepositoryLinksTests.cs @@ -16,17 +16,13 @@ public class RepositoryLinksTests : NavigationTestsBase private RepositoryLinks Reference { get; } [Fact] - public void ShouldNotBeNull() => - Reference.Should().NotBeNull(); + public void ShouldNotBeNull() => Reference.Should().NotBeNull(); [Fact] - public void EmitsLinks() => - Reference.Links.Should().NotBeNullOrEmpty(); + public void EmitsLinks() => Reference.Links.Should().NotBeNullOrEmpty(); [Fact] - public void ShouldNotIncludeSnippets() => - Reference.Links.Should().NotContain(l => l.Key.Contains("_snippets/")); - + public void ShouldNotIncludeSnippets() => Reference.Links.Should().NotContain(l => l.Key.Contains("_snippets/")); } public class GitCheckoutInformationTests(ITestOutputHelper output) : NavigationTestsBase(output) @@ -56,12 +52,7 @@ public void SerializesCurrent() { var linkReference = new RepositoryLinks { - Origin = new GitCheckoutInformation - { - Branch = "branch", - Remote = "remote", - Ref = "ref" - }, + Origin = new GitCheckoutInformation { Branch = "branch", Remote = "remote", Ref = "ref" }, UrlPathPrefix = "", Links = [], CrossLinks = [], @@ -83,7 +74,8 @@ public void SerializesCurrent() "cross_links": [], "redirects": null } - """); + """ + ); } [Fact] @@ -109,5 +101,4 @@ public void Deserializes() var linkReference = RepositoryLinks.Deserialize(json); linkReference.Origin.Ref.Should().Be("ref"); } - } diff --git a/tests/Elastic.Markdown.Tests/Exporters/LlmMarkdownExporterTests.cs b/tests/Elastic.Markdown.Tests/Exporters/LlmMarkdownExporterTests.cs index d72f699bd9..d6ea1a940f 100644 --- a/tests/Elastic.Markdown.Tests/Exporters/LlmMarkdownExporterTests.cs +++ b/tests/Elastic.Markdown.Tests/Exporters/LlmMarkdownExporterTests.cs @@ -15,10 +15,7 @@ public class LlmMarkdownExporterTests public async Task FinishExportAsync_InMemoryFileSystem_CreatesArchiveFromInMemoryFiles() { const string outputPath = "/repo/.artifacts/docs/html"; - var fileSystem = new MockFileSystem(new Dictionary - { - [$"{outputPath}/guide/page.md"] = new("# Page") - }); + var fileSystem = new MockFileSystem(new Dictionary { [$"{outputPath}/guide/page.md"] = new("# Page") }); var outputFolder = fileSystem.DirectoryInfo.New(outputPath); var exporter = new LlmMarkdownExporter(); diff --git a/tests/Elastic.Markdown.Tests/Exporters/OkfMarkdownExporterTests.cs b/tests/Elastic.Markdown.Tests/Exporters/OkfMarkdownExporterTests.cs index 984effb636..fc6f3d5dc2 100644 --- a/tests/Elastic.Markdown.Tests/Exporters/OkfMarkdownExporterTests.cs +++ b/tests/Elastic.Markdown.Tests/Exporters/OkfMarkdownExporterTests.cs @@ -77,7 +77,11 @@ public void RewriteLinkUrl_InternalLinkWithAnchor_ReturnsBundleRelativePathWithA [Fact] public void RewriteLinkUrl_ExternalAbsoluteUrl_ReturnsUnchanged() { - var rewritten = OkfMarkdownExporter.RewriteLinkUrl("https://example.com/page", urlPathPrefix: "", canonicalBaseUrl: new Uri("https://www.elastic.co")); + var rewritten = OkfMarkdownExporter.RewriteLinkUrl( + "https://example.com/page", + urlPathPrefix: "", + canonicalBaseUrl: new Uri("https://www.elastic.co") + ); rewritten.Should().Be("https://example.com/page"); } @@ -114,7 +118,8 @@ public void RewriteLinkUrl_SelfReferencingAbsoluteUrlMatchingCanonicalBase_Unwra var rewritten = OkfMarkdownExporter.RewriteLinkUrl( "https://www.elastic.co/docs/deploy-manage/deploy#about-orchestration", urlPathPrefix: "/docs", - canonicalBaseUrl: new Uri("https://www.elastic.co")); + canonicalBaseUrl: new Uri("https://www.elastic.co") + ); rewritten.Should().Be("/deploy-manage/deploy.md#about-orchestration"); } @@ -127,7 +132,8 @@ public void RewriteLinkUrl_ApiReferencePath_ReturnsLiveSiteUrlUnchanged() var rewritten = OkfMarkdownExporter.RewriteLinkUrl( "https://www.elastic.co/docs/api/some-endpoint", urlPathPrefix: "/docs", - canonicalBaseUrl: new Uri("https://www.elastic.co")); + canonicalBaseUrl: new Uri("https://www.elastic.co") + ); rewritten.Should().Be("https://www.elastic.co/docs/api/some-endpoint"); } @@ -138,7 +144,8 @@ public void RewriteLinkUrl_RelativeApiReferencePath_ReturnsAbsoluteLiveSiteUrl() var rewritten = OkfMarkdownExporter.RewriteLinkUrl( "/docs/api/some-endpoint#section", urlPathPrefix: "/docs", - canonicalBaseUrl: new Uri("https://www.elastic.co")); + canonicalBaseUrl: new Uri("https://www.elastic.co") + ); rewritten.Should().Be("https://www.elastic.co/docs/api/some-endpoint#section"); } @@ -164,7 +171,8 @@ public void RewriteLinkUrl_AbsoluteUrlWithDifferentHost_ReturnsUnchangedEvenWith var rewritten = OkfMarkdownExporter.RewriteLinkUrl( "https://github.com/elastic/docs-builder", urlPathPrefix: "/docs", - canonicalBaseUrl: new Uri("https://www.elastic.co")); + canonicalBaseUrl: new Uri("https://www.elastic.co") + ); rewritten.Should().Be("https://github.com/elastic/docs-builder"); } @@ -185,24 +193,16 @@ public void IsUtilityPage_LandingPageOrNull_ReturnsFalse() } [Fact] - public void GetDirectory_NestedPath_ReturnsParentDirectory() - { + public void GetDirectory_NestedPath_ReturnsParentDirectory() => OkfMarkdownExporter.GetDirectory("reference/foo/bar.md").Should().Be("reference/foo"); - } [Fact] - public void GetDirectory_TopLevelFile_ReturnsEmptyString() - { - OkfMarkdownExporter.GetDirectory("overview.md").Should().Be(string.Empty); - } + public void GetDirectory_TopLevelFile_ReturnsEmptyString() => OkfMarkdownExporter.GetDirectory("overview.md").Should().Be(string.Empty); [Fact] public void RenderIndexContent_RootDirectory_DeclaresOkfVersionAndNoOtherFrontmatter() { - var content = OkfMarkdownExporter.RenderIndexContent( - directory: "", - concepts: [], - subdirectories: []); + var content = OkfMarkdownExporter.RenderIndexContent(directory: "", concepts: [], subdirectories: []); content.Should().StartWith("---\nokf_version: \"0.1\"\n---"); } @@ -210,10 +210,7 @@ public void RenderIndexContent_RootDirectory_DeclaresOkfVersionAndNoOtherFrontma [Fact] public void RenderIndexContent_NonRootDirectory_HasNoFrontmatter() { - var content = OkfMarkdownExporter.RenderIndexContent( - directory: "reference", - concepts: [], - subdirectories: []); + var content = OkfMarkdownExporter.RenderIndexContent(directory: "reference", concepts: [], subdirectories: []); content.Should().NotContain("---"); content.Should().NotContain("okf_version"); @@ -229,10 +226,7 @@ public void RenderIndexContent_WithConceptsAndSubdirectories_GroupsThemUnderSepa new("reference/foo.md", "Foo", "Foo description"), }; - var content = OkfMarkdownExporter.RenderIndexContent( - directory: "reference", - concepts: concepts, - subdirectories: ["reference/foo"]); + var content = OkfMarkdownExporter.RenderIndexContent(directory: "reference", concepts: concepts, subdirectories: ["reference/foo"]); content.Should().Contain("# Documents"); content.Should().Contain("* [Bar](bar.md) - Bar description"); @@ -244,10 +238,7 @@ public void RenderIndexContent_WithConceptsAndSubdirectories_GroupsThemUnderSepa [Fact] public void RenderIndexContent_SubdirectoryWithoutSiblingLandingPage_OmitsDescriptionSuffix() { - var content = OkfMarkdownExporter.RenderIndexContent( - directory: "reference", - concepts: [], - subdirectories: ["reference/foo"]); + var content = OkfMarkdownExporter.RenderIndexContent(directory: "reference", concepts: [], subdirectories: ["reference/foo"]); content.Should().Contain("* [foo](foo/)"); content.Should().NotContain("* [foo](foo/) -"); diff --git a/tests/Elastic.Markdown.Tests/FileInclusion/HeadingOrderTests.cs b/tests/Elastic.Markdown.Tests/FileInclusion/HeadingOrderTests.cs index f0e4128603..46620ff046 100644 --- a/tests/Elastic.Markdown.Tests/FileInclusion/HeadingOrderTests.cs +++ b/tests/Elastic.Markdown.Tests/FileInclusion/HeadingOrderTests.cs @@ -11,8 +11,9 @@ namespace Elastic.Markdown.Tests.FileInclusion; -public class IncludeHeadingOrderTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class IncludeHeadingOrderTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ## One ### Two ### Three @@ -39,8 +40,7 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) public void ParsesBlock() => Block.Should().NotBeNull(); [Fact] - public void IncludesSnippetAfterMainContent() => - Html.Should().Contain("Two").And.Contain("Six"); + public void IncludesSnippetAfterMainContent() => Html.Should().Contain("Two").And.Contain("Six"); [Fact] public void TableOfContentsRespectsOrder() @@ -61,25 +61,16 @@ public void TableOfContentsRespectsOrder() toc.Should().HaveCount(8); // Check the order is correct - var expectedOrder = new[] - { - "One", - "Two", - "Three", - "Four", - "Five", - "Six", - "Seven", - "Eight" - }; + var expectedOrder = new[] { "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight" }; var actualOrder = toc.Select(t => t.Heading).ToArray(); actualOrder.Should().Equal(expectedOrder); } } -public class IncludeBeforeHeadingsOrderTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class IncludeBeforeHeadingsOrderTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{include} _snippets/test.md ::: @@ -131,28 +122,16 @@ public void TableOfContentsRespectsOrderWithIncludeFirst() toc.Should().HaveCount(11); // Check the order is correct - var expectedOrder = new[] - { - "One", - "Two", - "Three", - "Four", - "Five", - "Six", - "Seven", - "Eight", - "Nine", - "Ten", - "Eleven" - }; + var expectedOrder = new[] { "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven" }; var actualOrder = toc.Select(t => t.Heading).ToArray(); actualOrder.Should().Equal(expectedOrder); } } -public class IncludeInMiddleOfHeadingsOrderTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class IncludeInMiddleOfHeadingsOrderTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ## One ### Two ### Three @@ -202,19 +181,7 @@ public void TableOfContentsRespectsOrderWithIncludeInMiddle() toc.Should().HaveCount(10); // Check the order is correct - var expectedOrder = new[] - { - "One", - "Two", - "Three", - "Four", - "Five", - "Six", - "Seven", - "Eight", - "Nine", - "Ten" - }; + var expectedOrder = new[] { "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten" }; var actualOrder = toc.Select(t => t.Heading).ToArray(); actualOrder.Should().Equal(expectedOrder); @@ -236,8 +203,9 @@ public void HeadingLevelsArePreservedFromSnippet() toc[5].Level.Should().Be(3, "h3 from snippet should remain level 3"); } } -public class IncludeWithStepperOrderTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class IncludeWithStepperOrderTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ## One ### Two @@ -267,7 +235,8 @@ public class IncludeWithStepperOrderTests(ITestOutputHelper output) : DirectiveT protected override void AddToFileSystem(MockFileSystem fileSystem) { // language=markdown - var inclusion = """ + var inclusion = + """ :::::{stepper} ::::{step} Six @@ -312,29 +281,16 @@ public void TableOfContentsRespectsOrderWithStepperAndInclude() toc.Should().HaveCount(12); // Check the order is correct - var expectedOrder = new[] - { - "One", - "Two", - "Three", - "Four", - "Five", - "Six", - "Seven", - "Eight", - "Nine", - "Ten", - "Eleven", - "Twelve" - }; + var expectedOrder = new[] { "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven", "Twelve" }; var actualOrder = toc.Select(t => t.Heading).ToArray(); actualOrder.Should().Equal(expectedOrder); } } -public class StepperBeforeIncludeOrderTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class StepperBeforeIncludeOrderTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::::{stepper} ::::{step} One @@ -360,7 +316,8 @@ Configuration step. protected override void AddToFileSystem(MockFileSystem fileSystem) { // language=markdown - var inclusion = """ + var inclusion = + """ ### Four ### Five @@ -402,18 +359,7 @@ public void TableOfContentsRespectsOrderWithStepperBeforeInclude() toc.Should().HaveCount(9); // Check the order is correct - var expectedOrder = new[] - { - "One", - "Two", - "Three", - "Four", - "Five", - "Six", - "Seven", - "Eight", - "Nine" - }; + var expectedOrder = new[] { "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine" }; var actualOrder = toc.Select(t => t.Heading).ToArray(); actualOrder.Should().Equal(expectedOrder); @@ -424,8 +370,9 @@ public void TableOfContentsRespectsOrderWithStepperBeforeInclude() /// Tests that stepper steps in included snippets inherit the correct heading level /// from the parent document's context. This is the key test for the DocumentTraversal fix. /// -public class StepperInIncludeHeadingLevelTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class StepperInIncludeHeadingLevelTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ## Main Heading Some intro content. @@ -440,7 +387,8 @@ Some intro content. protected override void AddToFileSystem(MockFileSystem fileSystem) { // The stepper in this snippet should get heading level 3 (one deeper than the ## heading before the include) - var inclusion = """ + var inclusion = + """ :::::{stepper} ::::{step} Step One @@ -488,8 +436,9 @@ public void StepperStepsInSnippetInheritCorrectHeadingLevel() /// /// Tests stepper heading levels with a deeper heading context (### before include). /// -public class StepperInIncludeWithH3ContextTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class StepperInIncludeWithH3ContextTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ## Main Heading ### Sub Heading @@ -547,8 +496,9 @@ public void StepperStepsInSnippetInheritDeeperHeadingLevel() /// /// Tests stepper in snippet when there's no preceding heading (should default to h2). /// -public class StepperInIncludeWithNoHeadingContextTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class StepperInIncludeWithNoHeadingContextTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{include} _snippets/stepper-snippet.md ::: @@ -594,8 +544,9 @@ public void StepperStepsDefaultToH2WhenNoHeadingContext() /// Tests that stepper steps in snippets respect their own snippet's heading structure /// and are NOT adjusted when the snippet has its own preceding heading. /// -public class StepperInSnippetWithOwnHeadingTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class StepperInSnippetWithOwnHeadingTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ## Parent Heading :::{include} _snippets/stepper-snippet.md @@ -655,8 +606,9 @@ public void StepperStepRespectsSnippetOwnHeadingStructure() /// /// Tests that stepper steps are capped at h6 even when preceding heading would push them deeper. /// -public class StepperInSnippetWithH6CappingTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class StepperInSnippetWithH6CappingTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ## H2 ### H3 #### H4 @@ -703,8 +655,9 @@ public void StepperStepIsCappedAtH6() /// /// Tests multiple includes with different heading contexts to ensure each is adjusted independently. /// -public class MultipleIncludesWithDifferentContextsTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class MultipleIncludesWithDifferentContextsTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ## First Section :::{include} _snippets/first.md @@ -781,8 +734,9 @@ public void EachIncludeAdjustsBasedOnItsOwnContext() /// their heading levels based on preceding headings. This ensures our changes didn't break /// the existing behavior for steppers in the main document. /// -public class StepperInMainDocumentTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class StepperInMainDocumentTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ## Main Heading ### Sub Heading @@ -852,8 +806,9 @@ public void StepperStepsInMainDocumentCalculateCorrectHeadingLevels() /// Tests that a heading at the same level as a step is auto-adjusted to one level deeper /// and that a hint diagnostic is emitted pointing to the heading. /// -public class StepperWithInternalHeadingAtSameLevelTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class StepperWithInternalHeadingAtSameLevelTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ## Section :::::{stepper} @@ -903,18 +858,21 @@ public void InternalHeadingIsAdjustedToOneLevelDeeper() [Fact] public void HintIsEmittedForAdjustedHeading() => - Collector.Diagnostics.Should().ContainSingle(d => - d.Severity == Documentation.Diagnostics.Severity.Hint && - d.Message.Contains("h3") && - d.Message.Contains("h4") && - d.Message.Contains("####")); + Collector.Diagnostics + .Should() + .ContainSingle( + d => + d.Severity == Documentation.Diagnostics.Severity.Hint && d.Message.Contains("h3") && d.Message.Contains("h4") && + d.Message.Contains("####") + ); } /// /// Tests that a heading already deeper than the step level is left untouched (no adjustment, no hint). /// -public class StepperWithDeepInternalHeadingTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class StepperWithDeepInternalHeadingTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ## Section :::::{stepper} @@ -948,15 +906,15 @@ public void DeepHeadingIsUntouched() } [Fact] - public void NoHintEmittedForValidHeading() => - Collector.Diagnostics.Should().BeEmpty(); + public void NoHintEmittedForValidHeading() => Collector.Diagnostics.Should().BeEmpty(); } /// /// Tests stepper steps at the beginning of a document (no preceding heading). /// -public class StepperAtDocumentStartTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class StepperAtDocumentStartTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::::{stepper} ::::{step} First Step @@ -1003,8 +961,9 @@ public void StepperStepsAtDocumentStartDefaultToH2() /// This directly guards the single-pass position index: if the index is wrong, the stepper levels /// in the second include will reflect the first include's heading context instead of the correct one. /// -public class MultipleIncludesInterleavedWithHeadingsTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class MultipleIncludesInterleavedWithHeadingsTests(ITestOutputHelper output) : DirectiveTest( + output, + """ ## Section A :::{include} _snippets/stepper-a.md @@ -1020,7 +979,8 @@ public class MultipleIncludesInterleavedWithHeadingsTests(ITestOutputHelper outp protected override void AddToFileSystem(MockFileSystem fileSystem) { // Stepper snippet — step level depends on preceding heading (## = level 2, step should become level 3) - fileSystem.AddFile(@"docs/_snippets/stepper-a.md", + fileSystem.AddFile( + @"docs/_snippets/stepper-a.md", """ :::::{stepper} @@ -1033,9 +993,11 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) :::: ::::: - """); + """ + ); - fileSystem.AddFile(@"docs/_snippets/stepper-b.md", + fileSystem.AddFile( + @"docs/_snippets/stepper-b.md", """ :::::{stepper} @@ -1048,7 +1010,8 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) :::: ::::: - """); + """ + ); } [Fact] diff --git a/tests/Elastic.Markdown.Tests/FileInclusion/IncludeTests.cs b/tests/Elastic.Markdown.Tests/FileInclusion/IncludeTests.cs index b28675f45f..4bb5520da8 100644 --- a/tests/Elastic.Markdown.Tests/FileInclusion/IncludeTests.cs +++ b/tests/Elastic.Markdown.Tests/FileInclusion/IncludeTests.cs @@ -10,13 +10,10 @@ namespace Elastic.Markdown.Tests.FileInclusion; - -public class IncludeTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class IncludeTests(ITestOutputHelper output) : DirectiveTest(output, """ :::{include} _snippets/test.md ::: -""" -) +""") { protected override void AddToFileSystem(MockFileSystem fileSystem) { @@ -29,13 +26,12 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) public void ParsesBlock() => Block.Should().NotBeNull(); [Fact] - public void IncludesInclusionHtml() => - Html.ShouldBeHtml("

    Hello world

    "); + public void IncludesInclusionHtml() => Html.ShouldBeHtml("

    Hello world

    "); } - -public class IncludeSubstitutionTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class IncludeSubstitutionTests(ITestOutputHelper output) : DirectiveTest( + output, + """ --- sub: foo: "bar" @@ -56,16 +52,12 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) public void ParsesBlock() => Block.Should().NotBeNull(); [Fact] - public void InclusionInheritsYamlContext() => - Html.Should() - .Contain("Hello bar") - .And.Be("

    Hello bar

    ") - ; + public void InclusionInheritsYamlContext() => Html.Should().Contain("Hello bar").And.Be("

    Hello bar

    "); } - -public class IncludeNotFoundTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class IncludeNotFoundTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{include} _snippets/notfound.md ::: """ @@ -82,17 +74,14 @@ public void EmitsError() { Collector.Diagnostics.Should().NotBeNullOrEmpty().And.HaveCount(1); Collector.Diagnostics.Should().OnlyContain(d => d.Severity == Severity.Error); - Collector.Diagnostics.Should() - .OnlyContain(d => d.Message.Contains("notfound.md` does not exist")); + Collector.Diagnostics.Should().OnlyContain(d => d.Message.Contains("notfound.md` does not exist")); } } -public class IncludeRequiresArgument(ITestOutputHelper output) : DirectiveTest(output, -""" +public class IncludeRequiresArgument(ITestOutputHelper output) : DirectiveTest(output, """ :::{include} ::: -""" -) +""") { [Fact] public void ParsesBlock() => Block.Should().NotBeNull(); @@ -105,13 +94,13 @@ public void EmitsError() { Collector.Diagnostics.Should().NotBeNullOrEmpty().And.HaveCount(1); Collector.Diagnostics.Should().OnlyContain(d => d.Severity == Severity.Error); - Collector.Diagnostics.Should() - .OnlyContain(d => d.Message.Contains("include requires an argument.")); + Collector.Diagnostics.Should().OnlyContain(d => d.Message.Contains("include requires an argument.")); } } -public class IncludeNeedsToLiveInSpecialFolder(ITestOutputHelper output) : DirectiveTest(output, -""" +public class IncludeNeedsToLiveInSpecialFolder(ITestOutputHelper output) : DirectiveTest( + output, + """ ```{include} test.md ``` """ @@ -135,46 +124,42 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) public void EmitsError() { Collector.Diagnostics.Should().NotBeNullOrEmpty(); - Collector.Diagnostics.Should() - .Contain(d => d.Severity == Severity.Error && - d.Message.Contains("only supports including snippets from `_snippet` folders.")); + Collector.Diagnostics + .Should() + .Contain(d => d.Severity == Severity.Error && d.Message.Contains("only supports including snippets from `_snippet` folders.")); } } - -public class IncludeRelativeTraversalBlocked(ITestOutputHelper output) : DirectiveTest(output, -""" +public class IncludeRelativeTraversalBlocked(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{include} ../../../outside.txt ::: """ ) { - protected override void AddToFileSystem(MockFileSystem fileSystem) => - fileSystem.AddFile(@"outside.txt", "some content"); + protected override void AddToFileSystem(MockFileSystem fileSystem) => fileSystem.AddFile(@"outside.txt", "some content"); [Fact] public void EmitsError() { Collector.Diagnostics.Should().NotBeNullOrEmpty(); - Collector.Diagnostics.Should() + Collector.Diagnostics + .Should() .Contain(d => d.Severity == Severity.Error && d.Message.Contains("must resolve within the documentation source directory")); } } - -public class CanNotIncludeItself(ITestOutputHelper output) : DirectiveTest(output, -""" +public class CanNotIncludeItself(ITestOutputHelper output) : DirectiveTest(output, """ ```{include} _snippets/test.md ``` -""" -) +""") { protected override void AddToFileSystem(MockFileSystem fileSystem) { // language=markdown - var inclusion = -""" + var inclusion = """ :::{include} test.md ::: """; @@ -192,7 +177,6 @@ public void EmitsError() { Collector.Diagnostics.Should().NotBeNullOrEmpty().And.HaveCount(1); Collector.Diagnostics.Should().OnlyContain(d => d.Severity == Severity.Error); - Collector.Diagnostics.Should() - .Contain(d => d.Message.Contains("cyclical include detected")); + Collector.Diagnostics.Should().Contain(d => d.Message.Contains("cyclical include detected")); } } diff --git a/tests/Elastic.Markdown.Tests/FileInclusion/IncludedAppliesSwitchTests.cs b/tests/Elastic.Markdown.Tests/FileInclusion/IncludedAppliesSwitchTests.cs index c2eaa768c3..f6ca8c8f7c 100644 --- a/tests/Elastic.Markdown.Tests/FileInclusion/IncludedAppliesSwitchTests.cs +++ b/tests/Elastic.Markdown.Tests/FileInclusion/IncludedAppliesSwitchTests.cs @@ -13,8 +13,9 @@ namespace Elastic.Markdown.Tests.FileInclusion; /// Tests that when the same snippet containing applies-switch is included multiple times, /// each include generates unique IDs to avoid HTML ID collisions. /// -public class IncludedAppliesSwitchTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class IncludedAppliesSwitchTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{include} _snippets/applies-switch.md ::: @@ -29,7 +30,7 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) { // language=markdown var snippet = -""" + """ ::::{applies-switch} :::{applies-item} stack: Content for Stack @@ -63,8 +64,9 @@ public void EachIncludeHasUniqueIds() /// /// Tests that a snippet with multiple applies-switches generates unique IDs for each one. /// -public class IncludedMultipleAppliesSwitchTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class IncludedMultipleAppliesSwitchTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{include} _snippets/multi-applies-switch.md ::: """ @@ -74,7 +76,7 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) { // language=markdown var snippet = -""" + """ ::::{applies-switch} :::{applies-item} stack: First switch - Stack diff --git a/tests/Elastic.Markdown.Tests/FileInclusion/IncludedTabSetTests.cs b/tests/Elastic.Markdown.Tests/FileInclusion/IncludedTabSetTests.cs index 028126dd63..e64fd1205d 100644 --- a/tests/Elastic.Markdown.Tests/FileInclusion/IncludedTabSetTests.cs +++ b/tests/Elastic.Markdown.Tests/FileInclusion/IncludedTabSetTests.cs @@ -13,8 +13,9 @@ namespace Elastic.Markdown.Tests.FileInclusion; /// Tests that when the same snippet containing tab-set is included multiple times, /// each include generates unique IDs to avoid HTML ID collisions. /// -public class IncludedTabSetTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class IncludedTabSetTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{include} _snippets/tab-set.md ::: @@ -29,7 +30,7 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) { // language=markdown var snippet = -""" + """ ::::{tab-set} :::{tab-item} First Content for first tab @@ -63,8 +64,9 @@ public void EachIncludeHasUniqueIds() /// /// Tests that a snippet with multiple tab-sets generates unique IDs for each one. /// -public class IncludedMultipleTabSetTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class IncludedMultipleTabSetTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{include} _snippets/multi-tab-set.md ::: """ @@ -74,7 +76,7 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) { // language=markdown var snippet = -""" + """ ::::{tab-set} :::{tab-item} First First tab set diff --git a/tests/Elastic.Markdown.Tests/FileInclusion/LiteralIncludeTests.cs b/tests/Elastic.Markdown.Tests/FileInclusion/LiteralIncludeTests.cs index df76fa2766..10470c707a 100644 --- a/tests/Elastic.Markdown.Tests/FileInclusion/LiteralIncludeTests.cs +++ b/tests/Elastic.Markdown.Tests/FileInclusion/LiteralIncludeTests.cs @@ -10,9 +10,9 @@ namespace Elastic.Markdown.Tests.FileInclusion; - -public class LiteralIncludeUsingPropertyTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class LiteralIncludeUsingPropertyTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{include} _snippets/test.txt :literal: true ::: @@ -30,15 +30,12 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) public void ParsesBlock() => Block.Should().NotBeNull(); [Fact] - public void IncludesInclusionHtml() => - Html.Should() - .Be("*Hello world*") - ; + public void IncludesInclusionHtml() => Html.Should().Be("*Hello world*"); } - -public class LiteralIncludeTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class LiteralIncludeTests(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{literalinclude} _snippets/test.md ::: """ @@ -55,67 +52,65 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) public void ParsesBlock() => Block.Should().NotBeNull(); [Fact] - public void IncludesInclusionHtml() => - Html.Should() - .Be("*Hello world*"); + public void IncludesInclusionHtml() => Html.Should().Be("*Hello world*"); } - -public class LiteralIncludeRelativeTraversalBlocked(ITestOutputHelper output) : DirectiveTest(output, -""" +public class LiteralIncludeRelativeTraversalBlocked(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{literalinclude} ../../../outside.txt ::: """ ) { - protected override void AddToFileSystem(MockFileSystem fileSystem) => - fileSystem.AddFile(@"outside.txt", "some content"); + protected override void AddToFileSystem(MockFileSystem fileSystem) => fileSystem.AddFile(@"outside.txt", "some content"); [Fact] public void EmitsError() { Collector.Diagnostics.Should().NotBeNullOrEmpty(); - Collector.Diagnostics.Should() + Collector.Diagnostics + .Should() .Contain(d => d.Severity == Severity.Error && d.Message.Contains("must resolve within the documentation source directory")); } } - -public class LiteralIncludeAbsoluteTraversalBlocked(ITestOutputHelper output) : DirectiveTest(output, -""" +public class LiteralIncludeAbsoluteTraversalBlocked(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{literalinclude} /../../../outside.txt ::: """ ) { - protected override void AddToFileSystem(MockFileSystem fileSystem) => - fileSystem.AddFile(@"outside.txt", "some content"); + protected override void AddToFileSystem(MockFileSystem fileSystem) => fileSystem.AddFile(@"outside.txt", "some content"); [Fact] public void EmitsError() { Collector.Diagnostics.Should().NotBeNullOrEmpty(); - Collector.Diagnostics.Should() + Collector.Diagnostics + .Should() .Contain(d => d.Severity == Severity.Error && d.Message.Contains("must resolve within the documentation source directory")); } } - -public class LiteralIncludeHiddenDirectoryBlocked(ITestOutputHelper output) : DirectiveTest(output, -""" +public class LiteralIncludeHiddenDirectoryBlocked(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{literalinclude} .config/data.txt ::: """ ) { - protected override void AddToFileSystem(MockFileSystem fileSystem) => - fileSystem.AddFile(@"docs/.config/data.txt", "some content"); + protected override void AddToFileSystem(MockFileSystem fileSystem) => fileSystem.AddFile(@"docs/.config/data.txt", "some content"); [Fact] public void EmitsError() { Collector.Diagnostics.Should().NotBeNullOrEmpty(); - Collector.Diagnostics.Should() + Collector.Diagnostics + .Should() .Contain(d => d.Severity == Severity.Error && d.Message.Contains("must not traverse hidden directories")); } } diff --git a/tests/Elastic.Markdown.Tests/FrontMatter/YamlFrontMatterTests.cs b/tests/Elastic.Markdown.Tests/FrontMatter/YamlFrontMatterTests.cs index e520cc4595..3b5a99f1be 100644 --- a/tests/Elastic.Markdown.Tests/FrontMatter/YamlFrontMatterTests.cs +++ b/tests/Elastic.Markdown.Tests/FrontMatter/YamlFrontMatterTests.cs @@ -7,8 +7,9 @@ namespace Elastic.Markdown.Tests.FrontMatter; -public class YamlFrontMatterTests(ITestOutputHelper output) : DirectiveTest(output, -""" +public class YamlFrontMatterTests(ITestOutputHelper output) : DirectiveTest( + output, + """ --- navigation_title: "Documentation Guide" sub: @@ -29,9 +30,7 @@ public class YamlFrontMatterTests(ITestOutputHelper output) : DirectiveTest(outp public void ReadsSubstitutions() { File.YamlFrontMatter.Should().NotBeNull(); - File.YamlFrontMatter.Properties.Should().NotBeEmpty() - .And.HaveCount(1) - .And.ContainKey("key"); + File.YamlFrontMatter.Properties.Should().NotBeEmpty().And.HaveCount(1).And.ContainKey("key"); } } @@ -45,12 +44,12 @@ public class EmptyFileWarnsNeedingATitle(ITestOutputHelper output) : DirectiveTe [Fact] public void WarnsOfNoTitle() => - Collector.Diagnostics.Should().NotBeEmpty() - .And.Contain(d => d.Message.Contains("Document has no title, using file name as title.")); + Collector.Diagnostics.Should().NotBeEmpty().And.Contain(d => d.Message.Contains("Document has no title, using file name as title.")); } -public class NavigationTitleSupportReplacements(ITestOutputHelper output) : DirectiveTest(output, -""" +public class NavigationTitleSupportReplacements(ITestOutputHelper output) : DirectiveTest( + output, + """ --- title: Elastic Docs v3 navigation_title: "Documentation Guide: {{key}}" @@ -64,28 +63,26 @@ public class NavigationTitleSupportReplacements(ITestOutputHelper output) : Dire public void ReadsNavigationTitle() => File.NavigationTitle.Should().Be("Documentation Guide: value"); } -public class ProductsSingle(ITestOutputHelper output) : DirectiveTest(output, - """ +public class ProductsSingle(ITestOutputHelper output) : DirectiveTest(output, """ --- products: - id: "apm" --- # APM - """ -) + """) { [Fact] public void ReadsProducts() { File.YamlFrontMatter.Should().NotBeNull(); - File.YamlFrontMatter.Products.Should().NotBeNull() - .And.HaveCount(1); + File.YamlFrontMatter.Products.Should().NotBeNull().And.HaveCount(1); File.YamlFrontMatter.Products.First().Id.Should().Be("apm"); } } -public class ProductsMultiple(ITestOutputHelper output) : DirectiveTest(output, +public class ProductsMultiple(ITestOutputHelper output) : DirectiveTest( + output, """ --- products: @@ -101,14 +98,14 @@ public class ProductsMultiple(ITestOutputHelper output) : DirectiveTest(output, public void ReadsProducts() { File.YamlFrontMatter.Should().NotBeNull(); - File.YamlFrontMatter.Products.Should().NotBeNull() - .And.HaveCount(2); + File.YamlFrontMatter.Products.Should().NotBeNull().And.HaveCount(2); File.YamlFrontMatter.Products.First().Id.Should().Be("apm"); File.YamlFrontMatter.Products.Last().Id.Should().Be("elasticsearch"); } } -public class ProductsSuggestionWhenMispelled(ITestOutputHelper output) : DirectiveTest(output, +public class ProductsSuggestionWhenMispelled(ITestOutputHelper output) : DirectiveTest( + output, """ --- products: @@ -123,11 +120,14 @@ public class ProductsSuggestionWhenMispelled(ITestOutputHelper output) : Directi public void HasErrors() { Collector.Diagnostics.Should().HaveCount(1); - Collector.Diagnostics.Should().Contain(d => d.Message.Contains("Invalid products frontmatter value: \"aapm\". Did you mean \"apm\"?")); + Collector.Diagnostics + .Should() + .Contain(d => d.Message.Contains("Invalid products frontmatter value: \"aapm\". Did you mean \"apm\"?")); } } -public class ProductsSuggestionWhenMispelled2(ITestOutputHelper output) : DirectiveTest(output, +public class ProductsSuggestionWhenMispelled2(ITestOutputHelper output) : DirectiveTest( + output, """ --- products: @@ -142,11 +142,14 @@ public class ProductsSuggestionWhenMispelled2(ITestOutputHelper output) : Direct public void HasErrors() { Collector.Diagnostics.Should().HaveCount(1); - Collector.Diagnostics.Should().Contain(d => d.Message.Contains("Invalid products frontmatter value: \"apmagent\". Did you mean \"apm-agent\"?")); + Collector.Diagnostics + .Should() + .Contain(d => d.Message.Contains("Invalid products frontmatter value: \"apmagent\". Did you mean \"apm-agent\"?")); } } -public class ProductsSuggestionWhenCasingError(ITestOutputHelper output) : DirectiveTest(output, +public class ProductsSuggestionWhenCasingError(ITestOutputHelper output) : DirectiveTest( + output, """ --- products: @@ -161,11 +164,14 @@ public class ProductsSuggestionWhenCasingError(ITestOutputHelper output) : Direc public void HasErrors() { Collector.Diagnostics.Should().HaveCount(1); - Collector.Diagnostics.Should().Contain(d => d.Message.Contains("Invalid products frontmatter value: \"Apm\". Did you mean \"apm\"?")); + Collector.Diagnostics + .Should() + .Contain(d => d.Message.Contains("Invalid products frontmatter value: \"Apm\". Did you mean \"apm\"?")); } } -public class ProductsSuggestionWhenEmpty(ITestOutputHelper output) : DirectiveTest(output, +public class ProductsSuggestionWhenEmpty(ITestOutputHelper output) : DirectiveTest( + output, """ --- products: @@ -180,11 +186,14 @@ public class ProductsSuggestionWhenEmpty(ITestOutputHelper output) : DirectiveTe public void HasErrors() { Collector.Diagnostics.Should().HaveCount(1); - Collector.Diagnostics.Should().Contain(d => d.Message.Contains("Invalid products frontmatter value: \"Product 'id' field is required.")); + Collector.Diagnostics + .Should() + .Contain(d => d.Message.Contains("Invalid products frontmatter value: \"Product 'id' field is required.")); } } -public class MappedPagesValidUrl(ITestOutputHelper output) : DirectiveTest(output, +public class MappedPagesValidUrl(ITestOutputHelper output) : DirectiveTest( + output, """ --- mapped_pages: @@ -196,13 +205,11 @@ public class MappedPagesValidUrl(ITestOutputHelper output) : DirectiveTest(outpu ) { [Fact] - public void NoErrors() - { - Collector.Diagnostics.Should().BeEmpty(); - } + public void NoErrors() => Collector.Diagnostics.Should().BeEmpty(); } -public class MappedPagesInvalidUrl(ITestOutputHelper output) : DirectiveTest(output, +public class MappedPagesInvalidUrl(ITestOutputHelper output) : DirectiveTest( + output, """ --- mapped_pages: @@ -217,11 +224,19 @@ public class MappedPagesInvalidUrl(ITestOutputHelper output) : DirectiveTest(out public void HasErrors() { Collector.Diagnostics.Should().HaveCount(1); - Collector.Diagnostics.Should().Contain(d => d.Message.Contains("Invalid mapped_pages URL: \"https://www.elastic.co/docs/get-started/deployment-options\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\". Please update the URL to reference content under the Elastic documentation guide.")); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Message.Contains( + "Invalid mapped_pages URL: \"https://www.elastic.co/docs/get-started/deployment-options\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\". Please update the URL to reference content under the Elastic documentation guide." + ) + ); } } -public class MappedPagesMixedUrls(ITestOutputHelper output) : DirectiveTest(output, +public class MappedPagesMixedUrls(ITestOutputHelper output) : DirectiveTest( + output, """ --- mapped_pages: @@ -238,11 +253,19 @@ public class MappedPagesMixedUrls(ITestOutputHelper output) : DirectiveTest(outp public void HasErrorsForInvalidUrl() { Collector.Diagnostics.Should().HaveCount(1); - Collector.Diagnostics.Should().Contain(d => d.Message.Contains("Invalid mapped_pages URL: \"https://www.elastic.co/docs/invalid-url\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\"")); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Message.Contains( + "Invalid mapped_pages URL: \"https://www.elastic.co/docs/invalid-url\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\"" + ) + ); } } -public class MappedPagesEmptyUrl(ITestOutputHelper output) : DirectiveTest(output, +public class MappedPagesEmptyUrl(ITestOutputHelper output) : DirectiveTest( + output, """ --- mapped_pages: @@ -261,7 +284,8 @@ public void NoErrorsForEmptyUrl() } } -public class MappedPagesExternalUrl(ITestOutputHelper output) : DirectiveTest(output, +public class MappedPagesExternalUrl(ITestOutputHelper output) : DirectiveTest( + output, """ --- mapped_pages: @@ -276,11 +300,19 @@ public class MappedPagesExternalUrl(ITestOutputHelper output) : DirectiveTest(ou public void HasErrorsForExternalUrl() { Collector.Diagnostics.Should().HaveCount(1); - Collector.Diagnostics.Should().Contain(d => d.Message.Contains("Invalid mapped_pages URL: \"https://github.com/elastic/docs-builder\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\"")); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Message.Contains( + "Invalid mapped_pages URL: \"https://github.com/elastic/docs-builder\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\"" + ) + ); } } -public class MappedPagesMalformedUri(ITestOutputHelper output) : DirectiveTest(output, +public class MappedPagesMalformedUri(ITestOutputHelper output) : DirectiveTest( + output, """ --- mapped_pages: @@ -295,11 +327,19 @@ public class MappedPagesMalformedUri(ITestOutputHelper output) : DirectiveTest(o public void HasErrorsForMalformedUri() { Collector.Diagnostics.Should().HaveCount(1); - Collector.Diagnostics.Should().Contain(d => d.Message.Contains("Invalid mapped_pages URL: \"https://www.elastic.co/guide/[invalid-characters]\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\"")); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Message.Contains( + "Invalid mapped_pages URL: \"https://www.elastic.co/guide/[invalid-characters]\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\"" + ) + ); } } -public class MappedPagesInvalidScheme(ITestOutputHelper output) : DirectiveTest(output, +public class MappedPagesInvalidScheme(ITestOutputHelper output) : DirectiveTest( + output, """ --- mapped_pages: @@ -314,11 +354,19 @@ public class MappedPagesInvalidScheme(ITestOutputHelper output) : DirectiveTest( public void HasErrorsForInvalidScheme() { Collector.Diagnostics.Should().HaveCount(1); - Collector.Diagnostics.Should().Contain(d => d.Message.Contains("Invalid mapped_pages URL: \"https://www.elastic.co/guide/invalid uri with spaces\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\"")); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Message.Contains( + "Invalid mapped_pages URL: \"https://www.elastic.co/guide/invalid uri with spaces\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\"" + ) + ); } } -public class MappedPagesNotAbsoluteUri(ITestOutputHelper output) : DirectiveTest(output, +public class MappedPagesNotAbsoluteUri(ITestOutputHelper output) : DirectiveTest( + output, """ --- mapped_pages: @@ -333,6 +381,13 @@ public class MappedPagesNotAbsoluteUri(ITestOutputHelper output) : DirectiveTest public void HasErrorsForNotAbsoluteUri() { Collector.Diagnostics.Should().HaveCount(1); - Collector.Diagnostics.Should().Contain(d => d.Message.Contains("Invalid mapped_pages URL: \"not-a-uri-at-all\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\"")); + Collector.Diagnostics + .Should() + .Contain( + d => + d.Message.Contains( + "Invalid mapped_pages URL: \"not-a-uri-at-all\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\"" + ) + ); } } diff --git a/tests/Elastic.Markdown.Tests/Helpers/StripMarkdownTests.cs b/tests/Elastic.Markdown.Tests/Helpers/StripMarkdownTests.cs index 7af40cd08a..9e9c2ac3db 100644 --- a/tests/Elastic.Markdown.Tests/Helpers/StripMarkdownTests.cs +++ b/tests/Elastic.Markdown.Tests/Helpers/StripMarkdownTests.cs @@ -23,8 +23,7 @@ public void DoesNotAllocate(string input) => public class StripMarkdown_EscapedAsterisks_StripsEscapes { [Fact] - public void UnescapesBackslashEscapedSpans() => - @"\*literal\*".StripMarkdown().Should().Be("*literal*"); + public void UnescapesBackslashEscapedSpans() => @"\*literal\*".StripMarkdown().Should().Be("*literal*"); } public class StripMarkdown_MarkdownInput_StripsFormatting @@ -34,6 +33,5 @@ public class StripMarkdown_MarkdownInput_StripsFormatting [InlineData("**bold text**", "bold text")] [InlineData("_italic text_", "italic text")] [InlineData("[link text](https://example.com)", "link text")] - public void RemovesMarkdownSyntax(string input, string expected) => - input.StripMarkdown().Should().Be(expected); + public void RemovesMarkdownSyntax(string input, string expected) => input.StripMarkdown().Should().Be(expected); } diff --git a/tests/Elastic.Markdown.Tests/Inline/AnchorLinkTests.cs b/tests/Elastic.Markdown.Tests/Inline/AnchorLinkTests.cs index c9f20ff1e4..560d27a7d8 100644 --- a/tests/Elastic.Markdown.Tests/Inline/AnchorLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/AnchorLinkTests.cs @@ -9,22 +9,23 @@ namespace Elastic.Markdown.Tests.Inline; -public abstract class AnchorLinkTestBase(ITestOutputHelper output, [LanguageInjection("markdown")] string content) - : InlineTest(output, -$""" +public abstract class AnchorLinkTestBase(ITestOutputHelper output, [LanguageInjection("markdown")] string content) : InlineTest( + output, + $""" ## Hello world A paragraph {content} -""") +""" +) { protected override void AddToFileSystem(MockFileSystem fileSystem) { // language=markdown var inclusion = -""" + """ # Special Requirements ## Sub Requirements @@ -45,27 +46,22 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) fileSystem.AddFile(@"docs/testing/req.md", inclusion); fileSystem.AddFile(@"docs/_static/img/observability.png", new MockFileData("")); } - } -public class InPageAnchorTests(ITestOutputHelper output) : AnchorLinkTestBase(output, -""" +public class InPageAnchorTests(ITestOutputHelper output) : AnchorLinkTestBase(output, """ [Hello](#hello-world) -""" -) +""") { [Fact] - public void GeneratesHtml() => - Html.ShouldContainHtml( - """

    Hello

    """ - ); + public void GeneratesHtml() => Html.ShouldContainHtml("""

    Hello

    """); [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class ExternalPageAnchorTests(ITestOutputHelper output) : AnchorLinkTestBase(output, -""" +public class ExternalPageAnchorTests(ITestOutputHelper output) : AnchorLinkTestBase( + output, + """ [Sub Requirements](testing/req.md#sub-requirements) """ ) @@ -81,9 +77,9 @@ public void GeneratesHtml() => public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } - -public class ExternalPageCustomAnchorTests(ITestOutputHelper output) : AnchorLinkTestBase(output, -""" +public class ExternalPageCustomAnchorTests(ITestOutputHelper output) : AnchorLinkTestBase( + output, + """ [Sub Requirements](testing/req.md#new-reqs) """ ) @@ -99,8 +95,9 @@ public void GeneratesHtml() => public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class ExternalPageAnchorAutoTitleTests(ITestOutputHelper output) : AnchorLinkTestBase(output, -""" +public class ExternalPageAnchorAutoTitleTests(ITestOutputHelper output) : AnchorLinkTestBase( + output, + """ [](testing/req.md#sub-requirements) """ ) @@ -115,26 +112,21 @@ public void GeneratesHtml() => public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } - -public class InPageBadAnchorTests(ITestOutputHelper output) : AnchorLinkTestBase(output, -""" +public class InPageBadAnchorTests(ITestOutputHelper output) : AnchorLinkTestBase(output, """ [Hello](#hello-world2) -""" -) +""") { [Fact] - public void GeneratesHtml() => - Html.ShouldContainHtml( - """

    Hello

    """ - ); + public void GeneratesHtml() => Html.ShouldContainHtml("""

    Hello

    """); [Fact] - public void HasError() => Collector.Diagnostics.Should().HaveCount(1) - .And.Contain(d => d.Message.Contains("`hello-world2` does not exist")); + public void HasError() => + Collector.Diagnostics.Should().HaveCount(1).And.Contain(d => d.Message.Contains("`hello-world2` does not exist")); } -public class ExternalPageBadAnchorTests(ITestOutputHelper output) : AnchorLinkTestBase(output, -""" +public class ExternalPageBadAnchorTests(ITestOutputHelper output) : AnchorLinkTestBase( + output, + """ [Sub Requirements](testing/req.md#sub-requirements2) """ ) @@ -146,12 +138,12 @@ public void GeneratesHtml() => ); [Fact] - public void HasError() => Collector.Diagnostics.Should().HaveCount(1) - .And.Contain(d => d.Message.Contains("`sub-requirements2` does not exist")); + public void HasError() => + Collector.Diagnostics.Should().HaveCount(1).And.Contain(d => d.Message.Contains("`sub-requirements2` does not exist")); } - -public class NestedHeadingTest(ITestOutputHelper output) : AnchorLinkTestBase(output, +public class NestedHeadingTest(ITestOutputHelper output) : AnchorLinkTestBase( + output, """ [Heading inside dropdown](testing/req.md#heading-inside-dropdown) """ @@ -166,36 +158,38 @@ public void GeneratesHtml() => public void HasError() => Collector.Diagnostics.Should().HaveCount(0); } -public class MissingMdExtensionTests(ITestOutputHelper output) : AnchorLinkTestBase(output, -""" +public class MissingMdExtensionTests(ITestOutputHelper output) : AnchorLinkTestBase(output, """ [Link](testing/req) -""" -) +""") { [Fact] - public void HasMdExtensionHintError() => Collector.Diagnostics.Should().HaveCount(1) - .And.Contain(d => d.Message.Contains("Did you forget to add the .md extension?")); + public void HasMdExtensionHintError() => + Collector.Diagnostics.Should().HaveCount(1).And.Contain(d => d.Message.Contains("Did you forget to add the .md extension?")); } -public class MissingMdExtensionWithAnchorTests(ITestOutputHelper output) : AnchorLinkTestBase(output, -""" +public class MissingMdExtensionWithAnchorTests(ITestOutputHelper output) : AnchorLinkTestBase( + output, + """ [Link](testing/req#sub-requirements) """ ) { [Fact] - public void HasMdExtensionHintError() => Collector.Diagnostics.Should().HaveCount(1) - .And.Contain(d => d.Message.Contains("Did you forget to add the .md extension?")); + public void HasMdExtensionHintError() => + Collector.Diagnostics.Should().HaveCount(1).And.Contain(d => d.Message.Contains("Did you forget to add the .md extension?")); } -public class MissingFileNoMdHintTests(ITestOutputHelper output) : AnchorLinkTestBase(output, -""" +public class MissingFileNoMdHintTests(ITestOutputHelper output) : AnchorLinkTestBase(output, """ [Link](testing/nonexistent) -""" -) +""") { [Fact] - public void HasGenericNotFoundError() => Collector.Diagnostics.Should().HaveCount(1) - .And.Contain(d => d.Message.Contains("does not exist")) - .And.NotContain(d => d.Message.Contains("Did you forget to add the .md extension?")); + public void HasGenericNotFoundError() => + Collector.Diagnostics + .Should() + .HaveCount(1) + .And + .Contain(d => d.Message.Contains("does not exist")) + .And + .NotContain(d => d.Message.Contains("Did you forget to add the .md extension?")); } diff --git a/tests/Elastic.Markdown.Tests/Inline/AutoLinkTests.cs b/tests/Elastic.Markdown.Tests/Inline/AutoLinkTests.cs index 5f31a380fd..35b84f0b29 100644 --- a/tests/Elastic.Markdown.Tests/Inline/AutoLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/AutoLinkTests.cs @@ -12,8 +12,10 @@ namespace Elastic.Markdown.Tests.Inline; /// /// Base class for autolink tests that expect a LinkInline to be found. /// -public abstract class AutoLinkTestBase(ITestOutputHelper output, [LanguageInjection("markdown")] string content) - : InlineTest(output, content) +public abstract class AutoLinkTestBase(ITestOutputHelper output, [LanguageInjection("markdown")] string content) : InlineTest( + output, + content +) { [Fact] public void ParsesBlock() => Block.Should().NotBeNull(); @@ -22,29 +24,28 @@ public abstract class AutoLinkTestBase(ITestOutputHelper output, [LanguageInject /// /// Base class for autolink tests that expect NO LinkInline to be found. /// -public abstract class AutoLinkNotFoundTestBase(ITestOutputHelper output, [LanguageInjection("markdown")] string content) - : InlineTest(output, content) +public abstract class AutoLinkNotFoundTestBase(ITestOutputHelper output, [LanguageInjection("markdown")] string content) : InlineTest( + output, + content +) { } -public class BasicAutoLinkTests(ITestOutputHelper output) : AutoLinkTestBase(output, -""" +public class BasicAutoLinkTests(ITestOutputHelper output) : AutoLinkTestBase(output, """ Check out https://docs.test.io for more info. -""" -) +""") { [Fact] public void GeneratesHtml() => - Html.Should().Contain( - """https://docs.test.io""" - ); + Html.Should().Contain("""https://docs.test.io"""); [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class AutoLinkWithPathTests(ITestOutputHelper output) : AutoLinkTestBase(output, -""" +public class AutoLinkWithPathTests(ITestOutputHelper output) : AutoLinkTestBase( + output, + """ Visit https://docs.test.io/path/to/page for details. """ ) @@ -59,8 +60,9 @@ public void GeneratesHtml() => public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class AutoLinkWithQueryStringTests(ITestOutputHelper output) : AutoLinkTestBase(output, -""" +public class AutoLinkWithQueryStringTests(ITestOutputHelper output) : AutoLinkTestBase( + output, + """ See https://docs.test.io/search?q=test&page=1 for results. """ ) @@ -75,8 +77,9 @@ public void GeneratesHtml() => public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class AutoLinkWithAnchorTests(ITestOutputHelper output) : AutoLinkTestBase(output, -""" +public class AutoLinkWithAnchorTests(ITestOutputHelper output) : AutoLinkTestBase( + output, + """ Jump to https://docs.test.io/page#section for the section. """ ) @@ -91,60 +94,56 @@ public void GeneratesHtml() => public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class AutoLinkTrailingPeriodTests(ITestOutputHelper output) : AutoLinkTestBase(output, -""" +public class AutoLinkTrailingPeriodTests(ITestOutputHelper output) : AutoLinkTestBase(output, """ Check out https://docs.test.io. -""" -) +""") { [Fact] public void ExcludesTrailingPeriod() => - Html.Should().Contain( - """https://docs.test.io.""" - ); + Html.Should().Contain("""https://docs.test.io."""); [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class AutoLinkTrailingCommaTests(ITestOutputHelper output) : AutoLinkTestBase(output, -""" +public class AutoLinkTrailingCommaTests(ITestOutputHelper output) : AutoLinkTestBase( + output, + """ Visit https://first.test.io, https://second.test.io, or https://third.test.io for info. """ ) { [Fact] public void ExcludesTrailingCommas() => - Html.Should().Contain( - """https://first.test.io,""" - ).And.Contain( - """https://second.test.io,""" - ).And.Contain( - """https://third.test.io""" - ); + Html.Should() + .Contain("""https://first.test.io,""") + .And + .Contain("""https://second.test.io,""") + .And + .Contain("""https://third.test.io"""); [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class AutoLinkInParenthesesTests(ITestOutputHelper output) : AutoLinkTestBase(output, -""" +public class AutoLinkInParenthesesTests(ITestOutputHelper output) : AutoLinkTestBase( + output, + """ See the docs (https://docs.test.io) for details. """ ) { [Fact] public void ExcludesClosingParen() => - Html.Should().Contain( - """(https://docs.test.io)""" - ); + Html.Should().Contain("""(https://docs.test.io)"""); [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class AutoLinkWithBalancedParensTests(ITestOutputHelper output) : AutoLinkTestBase(output, -""" +public class AutoLinkWithBalancedParensTests(ITestOutputHelper output) : AutoLinkTestBase( + output, + """ Check https://en.wikipedia.org/wiki/Rust_(programming_language) for more. """ ) @@ -159,8 +158,9 @@ public void IncludesBalancedParens() => public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class AutoLinkElasticDocsHintTests(ITestOutputHelper output) : AutoLinkTestBase(output, -""" +public class AutoLinkElasticDocsHintTests(ITestOutputHelper output) : AutoLinkTestBase( + output, + """ See https://www.elastic.co/docs/deploy-manage for deployment info. """ ) @@ -172,18 +172,18 @@ public void GeneratesHtml() => ); [Fact] - public void EmitsHint() - { - Collector.Diagnostics.Should().ContainSingle(d => - d.Severity == Severity.Hint && - d.Message.Contains("elastic.co/docs") && - d.Message.Contains("crosslink or relative link") - ); - } + public void EmitsHint() => + Collector.Diagnostics + .Should() + .ContainSingle( + d => + d.Severity == Severity.Hint && d.Message.Contains("elastic.co/docs") && d.Message.Contains("crosslink or relative link") + ); } -public class AutoLinkInCodeBlockTests(ITestOutputHelper output) : AutoLinkNotFoundTestBase(output, -""" +public class AutoLinkInCodeBlockTests(ITestOutputHelper output) : AutoLinkNotFoundTestBase( + output, + """ ``` https://docs.test.io/should/not/be/linked ``` @@ -191,79 +191,81 @@ public class AutoLinkInCodeBlockTests(ITestOutputHelper output) : AutoLinkNotFou ) { [Fact] - public void DoesNotCreateLink() => - Html.Should().NotContain(" - Html.Should().Contain("https://docs.test.io/api") - .And.NotContain("""https://docs.test.io/api").And.NotContain(""" Collector.Diagnostics.Should().HaveCount(0); } -public class AutoLinkDoesNotMatchHttpTests(ITestOutputHelper output) : AutoLinkNotFoundTestBase(output, -""" +public class AutoLinkDoesNotMatchHttpTests(ITestOutputHelper output) : AutoLinkNotFoundTestBase( + output, + """ This http://docs.test.io should not be autolinked. """ ) { [Fact] - public void DoesNotCreateLink() => - Html.Should().NotContain(" - Html.Should().Contain( - """Docs""" - ).And.Contain( - """https://other.test.io""" - ); + Html.Should() + .Contain("""Docs""") + .And + .Contain("""https://other.test.io"""); [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } // Regression test for elastic/docs-builder#3317: no nested when a URL is the link text. -public class AutoLinkInsideLinkTextTests(ITestOutputHelper output) : AutoLinkTestBase(output, -""" +public class AutoLinkInsideLinkTextTests(ITestOutputHelper output) : AutoLinkTestBase( + output, + """ Upload to a service like [https://gist.github.com](https://gist.github.com). """ ) { [Fact] public void DoesNotCreateNestedAnchor() => - Html.Should().Contain( - """https://gist.github.com""" - ).And.NotMatchRegex(@"]*>https://gist.github.com""") + .And + .NotMatchRegex(@"]*> Collector.Diagnostics.Should().HaveCount(0); } -public class AutoLinkInsideLinkTextWithSurroundingTextTests(ITestOutputHelper output) : AutoLinkTestBase(output, -""" +public class AutoLinkInsideLinkTextWithSurroundingTextTests(ITestOutputHelper output) : AutoLinkTestBase( + output, + """ See [the page at https://example.test.io for details](https://docs.test.io). """ ) @@ -279,8 +281,9 @@ public void DoesNotAutolinkUrlInsideLinkText() => } // Verify that image-inside-link is unaffected by the IsNestedInsideLink guard (images bypass it via the IsImage branch). -public class ImageInsideLinkTests(ITestOutputHelper output) : InlineTest(output, -""" +public class ImageInsideLinkTests(ITestOutputHelper output) : InlineTest( + output, + """ [![alt text](https://example.com/image.png)](https://example.com) """ ) @@ -290,24 +293,27 @@ public void RendersOuterAnchor() => Html.Should().Contain(""""""); [Fact] - public void RendersImage() => - Html.Should().Contain(" Html.Should().Contain(" Collector.Diagnostics.Should().HaveCount(0); } -public class MultipleAutoLinksTests(ITestOutputHelper output) : AutoLinkTestBase(output, -""" +public class MultipleAutoLinksTests(ITestOutputHelper output) : AutoLinkTestBase( + output, + """ First https://first.com then https://second.com and finally https://third.com are all linked. """ ) { [Fact] public void AllLinksAreCreated() => - Html.Should().Contain(""" Collector.Diagnostics.Should().HaveCount(0); @@ -315,112 +321,108 @@ public void AllLinksAreCreated() => // === Exclusion rule tests === -public class AutoLinkWithPortExclusionTests(ITestOutputHelper output) : AutoLinkNotFoundTestBase(output, -""" +public class AutoLinkWithPortExclusionTests(ITestOutputHelper output) : AutoLinkNotFoundTestBase( + output, + """ Connect to https://www.elastic.co:443/guide for the guide. """ ) { [Fact] - public void DoesNotCreateLinkForUrlWithPort() => - Html.Should().NotContain(" Html.Should().NotContain(" Collector.Diagnostics.Should().HaveCount(0); } -public class AutoLinkLocalhostExclusionTests(ITestOutputHelper output) : AutoLinkNotFoundTestBase(output, -""" +public class AutoLinkLocalhostExclusionTests(ITestOutputHelper output) : AutoLinkNotFoundTestBase( + output, + """ Check https://localhost/api for the local API. """ ) { [Fact] - public void DoesNotCreateLinkForLocalhost() => - Html.Should().NotContain(" Html.Should().NotContain(" Collector.Diagnostics.Should().HaveCount(0); } -public class AutoLinkLoopbackExclusionTests(ITestOutputHelper output) : AutoLinkNotFoundTestBase(output, -""" +public class AutoLinkLoopbackExclusionTests(ITestOutputHelper output) : AutoLinkNotFoundTestBase( + output, + """ Test at https://127.0.0.1/health for health check. """ ) { [Fact] - public void DoesNotCreateLinkForLoopback() => - Html.Should().NotContain(" Html.Should().NotContain(" Collector.Diagnostics.Should().HaveCount(0); } -public class AutoLinkExampleDomainExclusionTests(ITestOutputHelper output) : AutoLinkNotFoundTestBase(output, -""" +public class AutoLinkExampleDomainExclusionTests(ITestOutputHelper output) : AutoLinkNotFoundTestBase( + output, + """ See https://example.com/docs for examples. """ ) { [Fact] - public void DoesNotCreateLinkForExampleDomain() => - Html.Should().NotContain(" Html.Should().NotContain(" Collector.Diagnostics.Should().HaveCount(0); } -public class AutoLinkExampleSubdomainExclusionTests(ITestOutputHelper output) : AutoLinkNotFoundTestBase(output, -""" +public class AutoLinkExampleSubdomainExclusionTests(ITestOutputHelper output) : AutoLinkNotFoundTestBase( + output, + """ Visit https://system.example.com/setup for setup. """ ) { [Fact] public void DoesNotCreateLinkForExampleSubdomain() => - Html.Should().NotContain(" Collector.Diagnostics.Should().HaveCount(0); } -public class AutoLinkTemplatePlaceholderExclusionTests(ITestOutputHelper output) : AutoLinkNotFoundTestBase(output, -""" +public class AutoLinkTemplatePlaceholderExclusionTests(ITestOutputHelper output) : AutoLinkNotFoundTestBase( + output, + """ Use https://{{cluster_id}}.es.test.co/api for your cluster. """ ) { [Fact] - public void DoesNotCreateLinkForTemplatePlaceholder() => - Html.Should().NotContain(" Html.Should().NotContain(" - Html.Should().NotContain(""" Html.Should().NotContain(""" Collector.Diagnostics.Should().HaveCount(0); } -public class AutoLinkValidUrlStillWorksTests(ITestOutputHelper output) : AutoLinkTestBase(output, -""" +public class AutoLinkValidUrlStillWorksTests(ITestOutputHelper output) : AutoLinkTestBase( + output, + """ Check https://www.elastic.co/guide for docs. """ ) diff --git a/tests/Elastic.Markdown.Tests/Inline/CommentTest.cs b/tests/Elastic.Markdown.Tests/Inline/CommentTest.cs index a2e6009c8b..29a578167a 100644 --- a/tests/Elastic.Markdown.Tests/Inline/CommentTest.cs +++ b/tests/Elastic.Markdown.Tests/Inline/CommentTest.cs @@ -5,33 +5,25 @@ namespace Elastic.Markdown.Tests.Inline; -public class CommentTest(ITestOutputHelper output) : InlineTest(output, -""" +public class CommentTest(ITestOutputHelper output) : InlineTest(output, """ % comment not a comment -""" -) +""") { [Fact] public void GeneratesAttributesInHtml() { // language=html - Html.Should().NotContain( - """

    % comment""" - ) - .And.Contain( - """

    not a comment

    """ - ); - Html.ShouldBeHtml( - """ + Html.Should().NotContain("""

    % comment""").And.Contain("""

    not a comment

    """); + Html.ShouldBeHtml("""

    not a comment

    - """ - ); + """); } } -public class MultipleLineCommentTest(ITestOutputHelper output) : InlineTest(output, +public class MultipleLineCommentTest(ITestOutputHelper output) : InlineTest( + output, """ not a comment, and multi line comment below " ) - .And.ContainAll( - "

    not a comment, and multi line comment below

    ", - "

    also not a comment

    " - ); + .And + .ContainAll("

    not a comment, and multi line comment below

    ", "

    also not a comment

    "); Html.ShouldBeHtml( """

    not a comment, and multi line comment below

    @@ -69,7 +60,8 @@ public void GeneratesAttributesInHtml() } } -public class MultipleLineCommentWithLinkTest(ITestOutputHelper output) : InlineTest(output, +public class MultipleLineCommentWithLinkTest(ITestOutputHelper output) : InlineTest( + output, """ not a comment, and multi line comment below ") - .And.ContainAll( - "

    not a comment, and multi line comment below

    ", - "

    also not a comment

    " - ); + "

    -->" + ) + .And + .ContainAll("

    not a comment, and multi line comment below

    ", "

    also not a comment

    "); Html.ShouldBeHtml( """

    not a comment, and multi line comment below

    @@ -114,7 +107,8 @@ public void GeneratesAttributesInHtml() /// Tests for GitHub issue #2456: Silent build errors on malformed multiline comments. /// When closing --> is on the same line as other content, the comment should still close properly. /// -public class CommentWithClosingTagAtEndOfLineTest(ITestOutputHelper output) : InlineTest(output, +public class CommentWithClosingTagAtEndOfLineTest(ITestOutputHelper output) : InlineTest( + output, """ content before comment @@ -136,18 +130,17 @@ public void ContentAfterCommentShouldBeRendered() } [Fact] - public void ContentBeforeCommentShouldBeRendered() => - Html.Should().Contain("

    content before comment

    "); + public void ContentBeforeCommentShouldBeRendered() => Html.Should().Contain("

    content before comment

    "); [Fact] - public void CommentContentShouldNotBeRendered() => - Html.Should().NotContain("TODO: Uncomment once page is live."); + public void CommentContentShouldNotBeRendered() => Html.Should().NotContain("TODO: Uncomment once page is live."); } /// /// Tests single-line HTML comments like /// -public class SingleLineCommentTest(ITestOutputHelper output) : InlineTest(output, +public class SingleLineCommentTest(ITestOutputHelper output) : InlineTest( + output, """ content before @@ -158,22 +151,18 @@ content after ) { [Fact] - public void ContentBeforeAndAfterShouldBeRendered() - { - Html.Should() - .Contain("

    content before

    ") - .And.Contain("

    content after

    "); - } + public void ContentBeforeAndAfterShouldBeRendered() => + Html.Should().Contain("

    content before

    ").And.Contain("

    content after

    "); [Fact] - public void CommentContentShouldNotBeRendered() => - Html.Should().NotContain("single line comment"); + public void CommentContentShouldNotBeRendered() => Html.Should().NotContain("single line comment"); } /// /// Tests comment with opening and content on same line, closing on different line /// -public class CommentWithOpeningContentOnSameLineTest(ITestOutputHelper output) : InlineTest(output, +public class CommentWithOpeningContentOnSameLineTest(ITestOutputHelper output) : InlineTest( + output, """ content before @@ -186,19 +175,10 @@ content after ) { [Fact] - public void ContentBeforeAndAfterShouldBeRendered() - { - Html.Should() - .Contain("

    content before

    ") - .And.Contain("

    content after

    "); - } + public void ContentBeforeAndAfterShouldBeRendered() => + Html.Should().Contain("

    content before

    ").And.Contain("

    content after

    "); [Fact] - public void CommentContentShouldNotBeRendered() - { - Html.Should() - .NotContain("start of comment") - .And.NotContain("middle of comment") - .And.NotContain("end of comment"); - } + public void CommentContentShouldNotBeRendered() => + Html.Should().NotContain("start of comment").And.NotContain("middle of comment").And.NotContain("end of comment"); } diff --git a/tests/Elastic.Markdown.Tests/Inline/DirectiveBlockLinkTests.cs b/tests/Elastic.Markdown.Tests/Inline/DirectiveBlockLinkTests.cs index 369241751d..b136b24c0c 100644 --- a/tests/Elastic.Markdown.Tests/Inline/DirectiveBlockLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/DirectiveBlockLinkTests.cs @@ -9,9 +9,12 @@ namespace Elastic.Markdown.Tests.Inline; -public abstract class DirectiveBlockLinkTests(ITestOutputHelper output, [LanguageInjection("markdown")] string content) - : InlineTest(output, -$$""" +public abstract class DirectiveBlockLinkTests( + ITestOutputHelper output, + [LanguageInjection("markdown")] string content +) : InlineTest( + output, + $$""" :::{warning} :name: caution_ref This is a 'warning' admonition @@ -19,13 +22,13 @@ This is a 'warning' admonition {{content}} -""") +""" +) { protected override void AddToFileSystem(MockFileSystem fileSystem) { // language=markdown - var inclusion = -""" + var inclusion = """ # Special Requirements :::{important} @@ -36,37 +39,30 @@ This is an 'important' admonition fileSystem.AddFile(@"docs/testing/req.md", inclusion); fileSystem.AddFile(@"docs/_static/img/observability.png", new MockFileData("")); } - } -public class InPageDirectiveLinkTests(ITestOutputHelper output) : DirectiveBlockLinkTests(output, -""" +public class InPageDirectiveLinkTests(ITestOutputHelper output) : DirectiveBlockLinkTests(output, """ [Hello](#caution_ref) -""" -) +""") { [Fact] public void GeneratesHtml() => // language=html - Html.ShouldContainHtml( - """

    Hello

    """ - ); + Html.ShouldContainHtml("""

    Hello

    """); [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class ExternalDirectiveLinkTests(ITestOutputHelper output) : DirectiveBlockLinkTests(output, -""" +public class ExternalDirectiveLinkTests(ITestOutputHelper output) : DirectiveBlockLinkTests( + output, + """ [Sub Requirements](testing/req.md#hint_ref) """ ) { [Fact] - public void GeneratesHtml() => - Html.ShouldContainHtml( - """

    Sub Requirements

    """ - ); + public void GeneratesHtml() => Html.ShouldContainHtml("""

    Sub Requirements

    """); [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); diff --git a/tests/Elastic.Markdown.Tests/Inline/FootnotesTests.cs b/tests/Elastic.Markdown.Tests/Inline/FootnotesTests.cs index 8853a9445a..6d0b03504b 100644 --- a/tests/Elastic.Markdown.Tests/Inline/FootnotesTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/FootnotesTests.cs @@ -7,14 +7,16 @@ namespace Elastic.Markdown.Tests.Inline; -public class FootnotesBasicTests(ITestOutputHelper output) : InlineTest(output, +public class FootnotesBasicTests(ITestOutputHelper output) : InlineTest( + output, // language=markdown """ Here's a simple footnote[^1] and another[^2]. [^1]: This is the first footnote. [^2]: This is the second footnote. - """) + """ +) { [Fact] public void ContainsFootnoteReferences() @@ -25,10 +27,7 @@ public void ContainsFootnoteReferences() } [Fact] - public void ContainsFootnoteContainer() - { - Html.Should().Contain("class=\"footnotes\""); - } + public void ContainsFootnoteContainer() => Html.Should().Contain("class=\"footnotes\""); [Fact] public void ContainsFootnoteDefinitions() @@ -48,10 +47,7 @@ public void ContainsBackReferences() } [Fact] - public void RendersFootnotesHeading() - { - Html.Should().Contain("

    Footnotes

    "); - } + public void RendersFootnotesHeading() => Html.Should().Contain("

    Footnotes

    "); [Fact] public void FootnotesHeadingPrecedesFootnoteContainer() @@ -69,13 +65,15 @@ public void FootnotesHeadingPrecedesFootnoteContainer() } } -public partial class FootnotesMultipleReferencesTests(ITestOutputHelper output) : InlineTest(output, +public partial class FootnotesMultipleReferencesTests(ITestOutputHelper output) : InlineTest( + output, // language=markdown """ First reference[^1] and second reference[^1]. [^1]: This footnote is referenced twice. - """) + """ +) { [Fact] public void ContainsMultipleReferencesToSameFootnote() @@ -98,7 +96,8 @@ public void ContainsMultipleBackReferences() private static partial System.Text.RegularExpressions.Regex MyRegex(); } -public class FootnotesComplexContentTests(ITestOutputHelper output) : InlineTest(output, +public class FootnotesComplexContentTests(ITestOutputHelper output) : InlineTest( + output, // language=markdown """ Here's a complex footnote[^complex]. @@ -111,7 +110,8 @@ It has multiple paragraphs. - List item 1 - List item 2 - """) + """ +) { [Fact] public void ContainsComplexFootnoteStructure() @@ -136,7 +136,8 @@ public void ContainsListInFootnote() } } -public class FootnotesWithCodeTests(ITestOutputHelper output) : InlineTest(output, +public class FootnotesWithCodeTests(ITestOutputHelper output) : InlineTest( + output, // language=markdown """ See the code example[^code]. @@ -147,7 +148,8 @@ See the code example[^code]. def hello(): print("Hello, world!") ``` - """) + """ +) { [Fact] public void ContainsCodeBlockInFootnote() @@ -157,7 +159,8 @@ public void ContainsCodeBlockInFootnote() } } -public class FootnotesConsecutiveDefinitionsTests(ITestOutputHelper output) : InlineTest(output, +public class FootnotesConsecutiveDefinitionsTests(ITestOutputHelper output) : InlineTest( + output, // language=markdown """ First[^1], second[^2], third[^3]. @@ -165,7 +168,8 @@ public class FootnotesConsecutiveDefinitionsTests(ITestOutputHelper output) : In [^1]: First footnote. [^2]: Second footnote. [^3]: Third footnote. - """) + """ +) { [Fact] public void HandlesConsecutiveFootnoteDefinitions() @@ -184,14 +188,16 @@ public void AllFootnoteReferencesAreLinked() } } -public class FootnotesInListTests(ITestOutputHelper output) : InlineTest(output, +public class FootnotesInListTests(ITestOutputHelper output) : InlineTest( + output, // language=markdown """ - Item one - Item two with footnote[^list] [^list]: Footnote from list item. - """) + """ +) { [Fact] public void FootnoteWorksInListItem() @@ -201,13 +207,15 @@ public void FootnoteWorksInListItem() } } -public class FootnotesWithNamedReferencesTests(ITestOutputHelper output) : InlineTest(output, +public class FootnotesWithNamedReferencesTests(ITestOutputHelper output) : InlineTest( + output, // language=markdown """ Named reference[^my-footnote]. [^my-footnote]: This uses a named identifier. - """) + """ +) { [Fact] public void HandlesNamedFootnoteIdentifiers() @@ -217,13 +225,15 @@ public void HandlesNamedFootnoteIdentifiers() } } -public partial class FootnotesInlineCodeNotParsedTests(ITestOutputHelper output) : InlineTest(output, +public partial class FootnotesInlineCodeNotParsedTests(ITestOutputHelper output) : InlineTest( + output, // language=markdown """ Real reference[^1]. Inline code example: `[^1]` should not be parsed. [^1]: This is the footnote. - """) + """ +) { [Fact] public void InlineCodeFootnoteSyntaxNotParsed() @@ -244,7 +254,8 @@ public void OnlyOneBackReference() private static partial System.Text.RegularExpressions.Regex BackRefRegex(); } -public partial class FootnotesCodeBlockNotParsedTests(ITestOutputHelper output) : InlineTest(output, +public partial class FootnotesCodeBlockNotParsedTests(ITestOutputHelper output) : InlineTest( + output, // language=markdown """ Real reference[^1]. @@ -254,13 +265,11 @@ Code block with [^1] reference. ``` [^1]: This is the footnote. - """) + """ +) { [Fact] - public void CodeBlockRendered() - { - Html.Should().Contain("language-markdown"); - } + public void CodeBlockRendered() => Html.Should().Contain("language-markdown"); [Fact] public void OnlyOneBackReference() @@ -274,7 +283,8 @@ public void OnlyOneBackReference() private static partial System.Text.RegularExpressions.Regex BackRefRegex(); } -public partial class FootnotesCodeBlockInDirectiveTests(ITestOutputHelper output) : InlineTest(output, +public partial class FootnotesCodeBlockInDirectiveTests(ITestOutputHelper output) : InlineTest( + output, // language=markdown """ ::::{tab-set} @@ -300,15 +310,13 @@ public partial class FootnotesCodeBlockInDirectiveTests(ITestOutputHelper output [^1]: This is the first footnote. [^2]: This is the second footnote. - """) + """ +) { private readonly ITestOutputHelper _output = output; [Fact] - public void CodeBlockRendered() - { - Html.Should().Contain("language-markdown"); - } + public void CodeBlockRendered() => Html.Should().Contain("language-markdown"); [Fact] public void CorrectBackReferenceCount() @@ -326,7 +334,8 @@ public void CorrectBackReferenceCount() private static partial System.Text.RegularExpressions.Regex BackRefRegex(); } -public class FootnotesInsideDirectiveTests(ITestOutputHelper output) : InlineTest(output, +public class FootnotesInsideDirectiveTests(ITestOutputHelper output) : InlineTest( + output, // language=markdown """ ::::{tab-set} @@ -350,7 +359,8 @@ public class FootnotesInsideDirectiveTests(ITestOutputHelper output) : InlineTes :::: [^1]: Footnote definitions must be at the document level, not inside directives. - """) + """ +) { [Fact] public void OtherInlineElementsWorkInsideDirectives() @@ -376,4 +386,3 @@ public void FootnoteDefinitionsAreAtDocumentLevel() Html.Should().Contain("Footnote definitions must be at the document level"); } } - diff --git a/tests/Elastic.Markdown.Tests/Inline/HardBreakTests.cs b/tests/Elastic.Markdown.Tests/Inline/HardBreakTests.cs index 63d4283a5e..a63e4be1dc 100644 --- a/tests/Elastic.Markdown.Tests/Inline/HardBreakTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/HardBreakTests.cs @@ -4,36 +4,21 @@ namespace Elastic.Markdown.Tests.Inline; -public class AllowBrTagTest(ITestOutputHelper output) - : InlineTest(output, - "Hello,
    World!") +public class AllowBrTagTest(ITestOutputHelper output) : InlineTest(output, "Hello,
    World!") { [Fact] - public void GeneratesHtml() => - Html.ShouldContainHtml( - "

    Hello,
    World!

    " - ); + public void GeneratesHtml() => Html.ShouldContainHtml("

    Hello,
    World!

    "); } -public class BrTagNeedsToBeExact(ITestOutputHelper output) - : InlineTest(output, - "Hello,
    World
    !") +public class BrTagNeedsToBeExact(ITestOutputHelper output) : InlineTest(output, "Hello,
    World
    !") { [Fact] - public void GeneratesHtml() => - Html.ShouldContainHtml( - "

    Hello,<br >World<br />!

    " - ); + public void GeneratesHtml() => Html.ShouldContainHtml("

    Hello,<br >World<br />!

    "); } -public class DisallowSpanTag(ITestOutputHelper output) - : InlineTest(output, - "Hello,World!") +public class DisallowSpanTag(ITestOutputHelper output) : InlineTest(output, "Hello,World!") { [Fact] // span tag is rendered as text - public void GeneratesHtml() => - Html.ShouldContainHtml( - "

    Hello,<span>World!</span>

    " - ); + public void GeneratesHtml() => Html.ShouldContainHtml("

    Hello,<span>World!</span>

    "); } diff --git a/tests/Elastic.Markdown.Tests/Inline/IconParserTests.cs b/tests/Elastic.Markdown.Tests/Inline/IconParserTests.cs index 9209ba5e8c..94cee0e31c 100644 --- a/tests/Elastic.Markdown.Tests/Inline/IconParserTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/IconParserTests.cs @@ -6,7 +6,8 @@ namespace Elastic.Markdown.Tests.Inline; -public class IconParserTests(ITestOutputHelper output) : InlineTest(output, +public class IconParserTests(ITestOutputHelper output) : InlineTest( + output, """ A check mark {icon}`check`. A cross {icon}`cross`. A warning {icon}`warning`. @@ -19,41 +20,50 @@ An unknown icon {icon}`not_a_real_icon` should not be replaced. { [Fact] public void Render() => - Html.Should().Contain("") - .And.Contain("") - .And.Contain("") - .And.NotContain("{icon}`check`") - .And.NotContain("{icon}`cross`") - .And.NotContain("{icon}`warning`") - .And.Contain("/this:apm_trace:is:not:an:icon") - .And.Contain(":invalid-icon:") - .And.Contain("::"); + Html.Should() + .Contain("") + .And + .Contain("") + .And + .Contain("") + .And + .NotContain("{icon}`check`") + .And + .NotContain("{icon}`cross`") + .And + .NotContain("{icon}`warning`") + .And + .Contain("/this:apm_trace:is:not:an:icon") + .And + .Contain(":invalid-icon:") + .And + .Contain("::"); } -public class IconInListItemTest(ITestOutputHelper output) : InlineTest(output, - """ +public class IconInListItemTest(ITestOutputHelper output) : InlineTest(output, """ - {icon}`check` A check mark. - """ -) + """) { [Fact] public void Render() => Html.Should() .Contain("") - .And.NotContain("{icon}`check`") - .And.NotContain("
  • "); + .And + .NotContain("{icon}`check`") + .And + .NotContain("
  • "); } -public class IconInHeadingShouldBeRemovedFromAnchor(ITestOutputHelper output) : InlineTest(output, - """ +public class IconInHeadingShouldBeRemovedFromAnchor(ITestOutputHelper output) : InlineTest(output, """ ## Users {icon}`check` - """ -) + """) { [Fact] public void Render() => Html.Should() .Contain("") - .And.Contain("Users ResolveUrlForBuildMode(string relativeAssetPath, Bu const string guideRelativePath = "setup/guide.md"; var files = new Dictionary { - ["docs/docset.yml"] = new( - $""" + ["docs/docset.yml"] = + new( + $""" project: test toc: - file: index.md - file: {guideRelativePath} """ - ), - ["docs/index.md"] = new( - $""" + ), + ["docs/index.md"] = + new( + $""" # Home ![Alt](setup/{relativeAssetPath}) """ - ), - ["docs/" + guideRelativePath] = new( - $""" + ), + ["docs/" + guideRelativePath] = + new( + $""" # Guide ![Alt]({relativeAssetPath}) """ - ), + ), ["docs/setup/" + relativeAssetPath] = new([]) }; - var fileSystem = new MockFileSystem(files, new MockFileSystemOptions - { - CurrentDirectory = Paths.WorkingDirectoryRoot.FullName - }); + var fileSystem = new MockFileSystem(files, new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); @@ -103,7 +104,11 @@ private async Task ResolveUrlForBuildMode(string relativeAssetPath, Bu await documentationSet.ResolveDirectoryTree(TestContext.Current.CancellationToken); // Normalize path for cross-platform compatibility (Windows uses backslashes) - (string, string)[] pathsToTest = [(guideRelativePath.Replace('/', Path.DirectorySeparatorChar), relativeAssetPath), ("index.md", $"setup{Path.DirectorySeparatorChar}{relativeAssetPath}")]; + (string, string)[] pathsToTest = + [ + (guideRelativePath.Replace('/', Path.DirectorySeparatorChar), relativeAssetPath), + ("index.md", $"setup{Path.DirectorySeparatorChar}{relativeAssetPath}") + ]; List toReturn = []; foreach (var normalizedPath in pathsToTest) @@ -117,7 +122,9 @@ private async Task ResolveUrlForBuildMode(string relativeAssetPath, Bu // expected Url (and minimal metadata for the surrounding API contract). _ = documentationSet.NavigationDocumentationFileLookup.Remove(markdownFile); documentationSet.NavigationDocumentationFileLookup.Add(markdownFile, new NavigationItemStub(navigationUrl)); - documentationSet.NavigationDocumentationFileLookup.TryGetValue(markdownFile, out var navigation).Should() + documentationSet.NavigationDocumentationFileLookup + .TryGetValue(markdownFile, out var navigation) + .Should() .BeTrue("navigation lookup should contain current page"); navigation?.Url.Should().Be(navigationUrl); @@ -136,7 +143,6 @@ private async Task ResolveUrlForBuildMode(string relativeAssetPath, Bu context.Build.BuildType.Should().Be(buildType); toReturn.Add(DiagnosticLinkInlineParser.UpdateRelativeUrl(context, normalizedPath.Item2)); - } await collector.StopAsync(TestContext.Current.CancellationToken); diff --git a/tests/Elastic.Markdown.Tests/Inline/InlineAnchorTests.cs b/tests/Elastic.Markdown.Tests/Inline/InlineAnchorTests.cs index 8945493a9d..0eb7ec8373 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlineAnchorTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlineAnchorTests.cs @@ -11,7 +11,8 @@ namespace Elastic.Markdown.Tests.Inline; -public class InlineAnchorTests(ITestOutputHelper output) : LeafTest(output, +public class InlineAnchorTests(ITestOutputHelper output) : LeafTest( + output, """ this is regular text and this $$$is-an-inline-anchor$$$ and this continues to be regular text """ @@ -31,7 +32,8 @@ public void GeneratesAttributesInHtml() => ); } -public class InlineAnchorAtStartTests(ITestOutputHelper output) : LeafTest(output, +public class InlineAnchorAtStartTests(ITestOutputHelper output) : LeafTest( + output, """ $$$is-an-inline-anchor$$$ and this continues to be regular text """ @@ -47,12 +49,11 @@ public void ParsesBlock() [Fact] public void GeneratesAttributesInHtml() => // language=html - Html.Should().Be( - """

    and this continues to be regular text

    """ - ); + Html.Should().Be("""

    and this continues to be regular text

    """); } -public class InlineAnchorAtEndTests(ITestOutputHelper output) : LeafTest(output, +public class InlineAnchorAtEndTests(ITestOutputHelper output) : LeafTest( + output, """ this is regular text and this $$$is-an-inline-anchor$$$ """ @@ -68,12 +69,11 @@ public void ParsesBlock() [Fact] public void GeneratesAttributesInHtml() => // language=html - Html.ShouldContainHtml( - """

    this is regular text and this

    """ - ); + Html.ShouldContainHtml("""

    this is regular text and this

    """); } -public class BadStartInlineAnchorTests(ITestOutputHelper output) : BlockTest(output, +public class BadStartInlineAnchorTests(ITestOutputHelper output) : BlockTest( + output, """ this is regular text and this $$is-an-inline-anchor$$$ """ @@ -82,12 +82,11 @@ this is regular text and this $$is-an-inline-anchor$$$ [Fact] public void GeneratesAttributesInHtml() => // language=html - Html.Should().Contain( - """

    this is regular text and this $$is-an-inline-anchor$$$

    """ - ); + Html.Should().Contain("""

    this is regular text and this $$is-an-inline-anchor$$$

    """); } -public class BadEndInlineAnchorTests(ITestOutputHelper output) : BlockTest(output, +public class BadEndInlineAnchorTests(ITestOutputHelper output) : BlockTest( + output, """ this is regular text and this $$$is-an-inline-anchor$$ """ @@ -96,16 +95,12 @@ this is regular text and this $$$is-an-inline-anchor$$ [Fact] public void GeneratesAttributesInHtml() => // language=html - Html.ShouldContainHtml( - """

    this is regular text and this $$$is-an-inline-anchor$$

    """ - ); + Html.ShouldContainHtml("""

    this is regular text and this $$$is-an-inline-anchor$$

    """); } -public class InlineAnchorInHeading(ITestOutputHelper output) : BlockTest(output, - """ +public class InlineAnchorInHeading(ITestOutputHelper output) : BlockTest(output, """ ## Hello world $$$my-anchor$$$ - """ -) + """) { [Fact] public void GeneratesAttributesInHtml() => @@ -118,11 +113,9 @@ public void GeneratesAttributesInHtml() => ); } -public class ExplicitSlugInHeader(ITestOutputHelper output) : BlockTest(output, - """ +public class ExplicitSlugInHeader(ITestOutputHelper output) : BlockTest(output, """ ## Hello world [#my-anchor] - """ -) + """) { [Fact] public void GeneratesAttributesInHtml() => @@ -136,10 +129,12 @@ public void GeneratesAttributesInHtml() => ); } - -public abstract class InlineAnchorLinkTestBase(ITestOutputHelper output, [LanguageInjection("markdown")] string content) - : InlineTest(output, -$""" +public abstract class InlineAnchorLinkTestBase( + ITestOutputHelper output, + [LanguageInjection("markdown")] string content +) : InlineTest( + output, + $""" ## Hello world A paragraph @@ -148,13 +143,14 @@ A paragraph $$$same-page-anchor$$$ -""") +""" +) { protected override void AddToFileSystem(MockFileSystem fileSystem) { // language=markdown var inclusion = -""" + """ # Special Requirements ## Sub Requirements @@ -170,28 +166,24 @@ With a custom anchor that exists temporarily. $$$custom-anchor$$$ fileSystem.AddFile(@"docs/testing/req.md", inclusion); fileSystem.AddFile(@"docs/_static/img/observability.png", new MockFileData("")); } - } -public class InlineAnchorCanBeLinkedToo(ITestOutputHelper output) : InlineAnchorLinkTestBase(output, -""" +public class InlineAnchorCanBeLinkedToo(ITestOutputHelper output) : InlineAnchorLinkTestBase(output, """ [Hello](#same-page-anchor) -""" -) +""") { [Fact] public void GeneratesHtml() => // language=html - Html.ShouldContainHtml( - """

    Hello

    """ - ); + Html.ShouldContainHtml("""

    Hello

    """); [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class ExternalPageInlineAnchorCanBeLinkedToo(ITestOutputHelper output) : InlineAnchorLinkTestBase(output, -""" +public class ExternalPageInlineAnchorCanBeLinkedToo(ITestOutputHelper output) : InlineAnchorLinkTestBase( + output, + """ [Sub Requirements](testing/req.md#custom-anchor) """ ) diff --git a/tests/Elastic.Markdown.Tests/Inline/InlineImageTest.cs b/tests/Elastic.Markdown.Tests/Inline/InlineImageTest.cs index ffe4cc40c6..094c230cac 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlineImageTest.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlineImageTest.cs @@ -6,8 +6,9 @@ namespace Elastic.Markdown.Tests.Inline; -public class InlineImageTest(ITestOutputHelper output) : InlineTest(output, -""" +public class InlineImageTest(ITestOutputHelper output) : InlineTest( + output, + """ ![Elasticsearch](/_static/img/observability.png) """ ) @@ -18,13 +19,12 @@ public class InlineImageTest(ITestOutputHelper output) : InlineTest( [Fact] public void GeneratesAttributesInHtml() => // language=html - Html.ShouldContainHtml( - """

    Elasticsearch

    """ - ); + Html.ShouldContainHtml("""

    Elasticsearch

    """); } -public class RelativeInlineImageTest(ITestOutputHelper output) : InlineTest(output, -""" +public class RelativeInlineImageTest(ITestOutputHelper output) : InlineTest( + output, + """ ![Elasticsearch](_static/img/observability.png) """ ) @@ -35,14 +35,13 @@ public class RelativeInlineImageTest(ITestOutputHelper output) : InlineTest // language=html - Html.ShouldContainHtml( - """

    Elasticsearch

    """ - ); + Html.ShouldContainHtml("""

    Elasticsearch

    """); } // Test image sizing with space before = -public class InlineImageWithSizingSpaceBeforeTest(ITestOutputHelper output) : InlineTest(output, -""" +public class InlineImageWithSizingSpaceBeforeTest(ITestOutputHelper output) : InlineTest( + output, + """ ![Elasticsearch](/_static/img/observability.png " =50%") """ ) @@ -59,8 +58,9 @@ public void GeneratesAttributesInHtml() => } // Test image sizing without space before = -public class InlineImageWithSizingNoSpaceBeforeTest(ITestOutputHelper output) : InlineTest(output, -""" +public class InlineImageWithSizingNoSpaceBeforeTest(ITestOutputHelper output) : InlineTest( + output, + """ ![Elasticsearch](/_static/img/observability.png "=50%") """ ) @@ -77,8 +77,9 @@ public void GeneratesAttributesInHtml() => } // Test image sizing with pixels -public class InlineImageWithPixelSizingTest(ITestOutputHelper output) : InlineTest(output, -""" +public class InlineImageWithPixelSizingTest(ITestOutputHelper output) : InlineTest( + output, + """ ![Elasticsearch](/_static/img/observability.png "=250x330") """ ) @@ -95,8 +96,9 @@ public void GeneratesAttributesInHtml() => } // Test image sizing with title and sizing — explicit title in markdown is ignored; alt text is always used as title -public class InlineImageWithTitleAndSizingTest(ITestOutputHelper output) : InlineTest(output, -""" +public class InlineImageWithTitleAndSizingTest(ITestOutputHelper output) : InlineTest( + output, + """ ![Elasticsearch](/_static/img/observability.png "My Title =50%") """ ) @@ -113,8 +115,9 @@ public void GeneratesAttributesInHtml() => } // Test image sizing with width only -public class InlineImageWithWidthOnlyTest(ITestOutputHelper output) : InlineTest(output, -""" +public class InlineImageWithWidthOnlyTest(ITestOutputHelper output) : InlineTest( + output, + """ ![Elasticsearch](/_static/img/observability.png "=250") """ ) diff --git a/tests/Elastic.Markdown.Tests/Inline/InlineLinkTests.cs b/tests/Elastic.Markdown.Tests/Inline/InlineLinkTests.cs index 173c363fad..a7f24694af 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlineLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlineLinkTests.cs @@ -10,16 +10,15 @@ namespace Elastic.Markdown.Tests.Inline; -public abstract class LinkTestBase(ITestOutputHelper output, [LanguageInjection("markdown")] string content) - : InlineTest( - output, - content, - new Dictionary - { - { "some-url-with-a-version", "https://github.com/elastic/fake-repo/tree/v1.17.0" }, - { "some-url-path-prefix", "/something" }, - } - ) +public abstract class LinkTestBase(ITestOutputHelper output, [LanguageInjection("markdown")] string content) : InlineTest( + output, + content, + new Dictionary + { + { "some-url-with-a-version", "https://github.com/elastic/fake-repo/tree/v1.17.0" }, + { "some-url-path-prefix", "/something" }, + } +) { [Fact] public void ParsesBlock() => Block.Should().NotBeNull(); @@ -27,8 +26,7 @@ public abstract class LinkTestBase(ITestOutputHelper output, [LanguageInjection( protected override void AddToFileSystem(MockFileSystem fileSystem) { // language=markdown - var inclusion = -""" + var inclusion = """ # Special Requirements To follow this tutorial you will need to install the following components: @@ -36,14 +34,11 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) fileSystem.AddFile(@"docs/testing/req.md", inclusion); fileSystem.AddFile(@"docs/_static/img/observability.png", new MockFileData("")); } - } -public class InlineLinkTests(ITestOutputHelper output) : LinkTestBase(output, -""" +public class InlineLinkTests(ITestOutputHelper output) : LinkTestBase(output, """ [Elasticsearch](/_static/img/observability.png) -""" -) +""") { [Fact] public void GeneratesHtml() => @@ -55,11 +50,9 @@ public void GeneratesHtml() => public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class LinkToPageTests(ITestOutputHelper output) : LinkTestBase(output, -""" +public class LinkToPageTests(ITestOutputHelper output) : LinkTestBase(output, """ [Requirements](testing/req.md) -""" -) +""") { [Fact] public void GeneratesHtml() => @@ -74,11 +67,9 @@ public void GeneratesHtml() => public void EmitsCrossLink() => Collector.CrossLinks.Should().HaveCount(0); } -public class InsertPageTitleTests(ITestOutputHelper output) : LinkTestBase(output, -""" +public class InsertPageTitleTests(ITestOutputHelper output) : LinkTestBase(output, """ [](testing/req.md) -""" -) +""") { [Fact] public void GeneratesHtml() => @@ -93,13 +84,11 @@ public void GeneratesHtml() => public void EmitsCrossLink() => Collector.CrossLinks.Should().HaveCount(0); } -public class RepositoryLinksTest(ITestOutputHelper output) : LinkTestBase(output, - """ +public class RepositoryLinksTest(ITestOutputHelper output) : LinkTestBase(output, """ [test][test] [test]: testing/req.md - """ -) + """) { [Fact] public void GeneratesHtml() => @@ -114,13 +103,11 @@ public void GeneratesHtml() => public void EmitsCrossLink() => Collector.CrossLinks.Should().HaveCount(0); } -public class CrossLinkReferenceTest(ITestOutputHelper output) : LinkTestBase(output, - """ +public class CrossLinkReferenceTest(ITestOutputHelper output) : LinkTestBase(output, """ [test][test] [test]: kibana://index.md - """ -) + """) { [Fact] public void GeneratesHtml() => @@ -139,12 +126,10 @@ public void EmitsCrossLink() } } -public class CrossLinkTest(ITestOutputHelper output) : LinkTestBase(output, - """ +public class CrossLinkTest(ITestOutputHelper output) : LinkTestBase(output, """ Go to [test](kibana://index.md) - """ -) + """) { [Fact] public void GeneratesHtml() => @@ -164,12 +149,10 @@ public void EmitsCrossLink() } } -public class CrossLinkEmptyTextTest(ITestOutputHelper output) : LinkTestBase(output, - """ +public class CrossLinkEmptyTextTest(ITestOutputHelper output) : LinkTestBase(output, """ Go to [](kibana://index.md) - """ -) + """) { [Fact] public void GeneratesHtml() => @@ -180,9 +163,7 @@ public void GeneratesHtml() => [Fact] public void HasError() => - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && - d.Message.Contains("empty link text")); + Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("empty link text")); [Fact] public void EmitsCrossLink() @@ -192,7 +173,8 @@ public void EmitsCrossLink() } } -public class CrossLinkEmptyTextNoTitleTest(ITestOutputHelper output) : LinkTestBase(output, +public class CrossLinkEmptyTextNoTitleTest(ITestOutputHelper output) : LinkTestBase( + output, """ Go to [](kibana://get-started/index.md) @@ -208,9 +190,7 @@ public void GeneratesHtml() => [Fact] public void HasError() => - Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && - d.Message.Contains("empty link text")); + Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("empty link text")); [Fact] public void EmitsCrossLink() @@ -220,7 +200,8 @@ public void EmitsCrossLink() } } -public class LinkWithUnresolvedInterpolationError(ITestOutputHelper output) : LinkTestBase(output, +public class LinkWithUnresolvedInterpolationError(ITestOutputHelper output) : LinkTestBase( + output, """ [global search field]({{this-variable-does-not-exist}}/introduction.html#kibana-navigation-search) """ @@ -231,11 +212,18 @@ public void HasErrors() { Collector.Diagnostics.Should().HaveCount(1); Collector.Diagnostics.First().Severity.Should().Be(Severity.Error); - Collector.Diagnostics.First().Message.Should().Contain("he url contains unresolved template expressions: '{{this-variable-does-not-exist}}/introduction.html#kibana-navigation-search'. Please check if there is an appropriate global or frontmatter subs variable."); + Collector.Diagnostics + .First() + .Message + .Should() + .Contain( + "he url contains unresolved template expressions: '{{this-variable-does-not-exist}}/introduction.html#kibana-navigation-search'. Please check if there is an appropriate global or frontmatter subs variable." + ); } } -public class ExternalLinksWithInterpolationSuccess(ITestOutputHelper output) : LinkTestBase(output, +public class ExternalLinksWithInterpolationSuccess(ITestOutputHelper output) : LinkTestBase( + output, """ [link to app]({{some-url-with-a-version}}) """ @@ -248,13 +236,11 @@ public void GeneratesHtml() => ); [Fact] - public void HasNoWarningsOrErrors() - { - Collector.Diagnostics.Should().HaveCount(0); - } + public void HasNoWarningsOrErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class InternalLinksWithInterpolationWarning(ITestOutputHelper output) : LinkTestBase(output, +public class InternalLinksWithInterpolationWarning(ITestOutputHelper output) : LinkTestBase( + output, """ [link to app]({{some-url-path-prefix}}/hello-world) """ @@ -265,31 +251,29 @@ public void HasWarnings() { Collector.Diagnostics.Should().HaveCount(1); Collector.Diagnostics.First().Severity.Should().Be(Severity.Error); - Collector.Diagnostics.First().Message.Should().Contain("Link is resolved to '/something/hello-world'. Only external links are allowed to be resolved from template expressions."); + Collector.Diagnostics + .First() + .Message + .Should() + .Contain( + "Link is resolved to '/something/hello-world'. Only external links are allowed to be resolved from template expressions." + ); } } - - - -public class NonExistingLinks(ITestOutputHelper output) : LinkTestBase(output, - """ +public class NonExistingLinks(ITestOutputHelper output) : LinkTestBase(output, """ [Non Existing Link](/non-existing.md) - """ -) + """) { [Fact] - public void HasErrors() => Collector.Diagnostics - .Where(d => d.Severity == Severity.Error) - .Should().HaveCount(1); + public void HasErrors() => Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Should().HaveCount(1); [Fact] - public void HasNoWarning() => Collector.Diagnostics - .Where(d => d.Severity == Severity.Warning) - .Should().HaveCount(0); + public void HasNoWarning() => Collector.Diagnostics.Where(d => d.Severity == Severity.Warning).Should().HaveCount(0); } -public class CommentedNonExistingLinks(ITestOutputHelper output) : LinkTestBase(output, +public class CommentedNonExistingLinks(ITestOutputHelper output) : LinkTestBase( + output, """ % [Non Existing Link](/non-existing.md) """ @@ -304,7 +288,8 @@ public void GeneratesHtml() => public void HasErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class CommentedNonExistingLinks2(ITestOutputHelper output) : LinkTestBase(output, +public class CommentedNonExistingLinks2(ITestOutputHelper output) : LinkTestBase( + output, """ % Hello, this is a [Non Existing Link](/non-existing.md). Links: @@ -325,13 +310,15 @@ public void GeneratesHtml() => - """); + """ + ); [Fact] public void HasErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class NonExistingLinkShouldFail(ITestOutputHelper output) : LinkTestBase(output, +public class NonExistingLinkShouldFail(ITestOutputHelper output) : LinkTestBase( + output, """ [Non Existing Link](/non-existing.md) - [Non Existing Link](/non-existing.md) @@ -345,15 +332,15 @@ This is another [Non Existing Link](/non-existing.md) public void HasErrors() => Collector.Diagnostics.Should().HaveCount(3); } -public class CursorProtocolLinkTest(ITestOutputHelper output) : LinkTestBase(output, +public class CursorProtocolLinkTest(ITestOutputHelper output) : LinkTestBase( + output, """ [Install with Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=elastic&config=eyJmb28iOiJiYXIifQ==) """ ) { [Fact] - public void GeneratesHtml() => - Html.Should().Contain("""href="cursor://"""); + public void GeneratesHtml() => Html.Should().Contain("""href="cursor://"""); [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); @@ -362,15 +349,15 @@ public void GeneratesHtml() => public void EmitsNoCrossLinks() => Collector.CrossLinks.Should().HaveCount(0); } -public class VscodeProtocolLinkTest(ITestOutputHelper output) : LinkTestBase(output, +public class VscodeProtocolLinkTest(ITestOutputHelper output) : LinkTestBase( + output, """ [Install VS Code Extension](vscode:extension/elastic.elasticsearch) """ ) { [Fact] - public void GeneratesHtml() => - Html.Should().Contain("""href="vscode:"""); + public void GeneratesHtml() => Html.Should().Contain("""href="vscode:"""); [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); @@ -379,15 +366,15 @@ public void GeneratesHtml() => public void EmitsNoCrossLinks() => Collector.CrossLinks.Should().HaveCount(0); } -public class VscodeInsidersProtocolLinkTest(ITestOutputHelper output) : LinkTestBase(output, +public class VscodeInsidersProtocolLinkTest(ITestOutputHelper output) : LinkTestBase( + output, """ [Install with VS Code Insiders](vscode-insiders:mcp/install?%7B%22name%22%3A%22oblt-cli%22%7D) """ ) { [Fact] - public void GeneratesHtml() => - Html.Should().Contain("""href="vscode-insiders:"""); + public void GeneratesHtml() => Html.Should().Contain("""href="vscode-insiders:"""); [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); diff --git a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs index bbc860da2e..e836f9ec17 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs @@ -13,61 +13,57 @@ namespace Elastic.Markdown.Tests.Inline; -public abstract class LeafTest(ITestOutputHelper output, [LanguageInjection("markdown")] string content) - : InlineTest(output, content) - where TDirective : LeafInline +public abstract class LeafTest(ITestOutputHelper output, [LanguageInjection("markdown")] string content) : InlineTest( + output, + content +) where TDirective : LeafInline { protected TDirective? Block { get; private set; } public override async ValueTask InitializeAsync() { await base.InitializeAsync(); - Block = Document - .Descendants() - .FirstOrDefault(); + Block = Document.Descendants().FirstOrDefault(); } [Fact] public void BlockIsNotNull() => Block.Should().NotBeNull(); - } -public abstract class BlockTest(ITestOutputHelper output, [LanguageInjection("markdown")] string content) - : InlineTest(output, content, new Dictionary { { "a-variable", "This is a variable" } }) - where TDirective : Block +public abstract class BlockTest(ITestOutputHelper output, [LanguageInjection("markdown")] string content) : InlineTest( + output, + content, + new Dictionary { { "a-variable", "This is a variable" } } +) where TDirective : Block { protected TDirective? Block { get; private set; } public override async ValueTask InitializeAsync() { await base.InitializeAsync(); - Block = Document - .Descendants() - .FirstOrDefault(); + Block = Document.Descendants().FirstOrDefault(); } [Fact] public void BlockIsNotNull() => Block.Should().NotBeNull(); - } -public abstract class InlineTest(ITestOutputHelper output, [LanguageInjection("markdown")] string content, Dictionary? globalVariables = null) - : InlineTest(output, content, globalVariables) - where TDirective : ContainerInline +public abstract class InlineTest( + ITestOutputHelper output, + [LanguageInjection("markdown")] string content, + Dictionary? globalVariables = null +) : InlineTest(output, content, globalVariables) where TDirective : ContainerInline { protected TDirective? Block { get; private set; } public override async ValueTask InitializeAsync() { await base.InitializeAsync(); - Block = Document - .Descendants() - .FirstOrDefault(); + Block = Document.Descendants().FirstOrDefault(); } [Fact] public void BlockIsNotNull() => Block.Should().NotBeNull(); - } public abstract class InlineTest : IAsyncLifetime { @@ -83,32 +79,33 @@ public abstract class InlineTest : IAsyncLifetime protected InlineTest( ITestOutputHelper output, [LanguageInjection("markdown")] string content, - Dictionary? globalVariables = null) + Dictionary? globalVariables = null + ) { var logger = new TestLoggerFactory(output); TestingFullDocument = string.IsNullOrEmpty(content) || content.StartsWith("---", StringComparison.OrdinalIgnoreCase); - var documentContents = TestingFullDocument ? content : -// language=markdown -$""" + var documentContents = TestingFullDocument + ? content + : + // language=markdown + $""" # Test Document {content} """; - FileSystem = new MockFileSystem(new Dictionary - { - { "docs/index.md", new MockFileData(documentContents) } - }, new MockFileSystemOptions - { - CurrentDirectory = Paths.WorkingDirectoryRoot.FullName, - }); + FileSystem = + new MockFileSystem( + new Dictionary { { "docs/index.md", new MockFileData(documentContents) } }, + new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName, } + ); // ReSharper disable once VirtualMemberCallInConstructor // nasty but sub implementations won't use class state. AddToFileSystem(FileSystem); var baseRootPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? Paths.WorkingDirectoryRoot.FullName.Replace('\\', '/') - : Paths.WorkingDirectoryRoot.FullName; + ? Paths.WorkingDirectoryRoot.FullName.Replace('\\', '/') + : Paths.WorkingDirectoryRoot.FullName; var root = FileSystem.DirectoryInfo.New($"{baseRootPath}/docs/"); FileSystem.GenerateDocSetYaml(root, globalVariables); @@ -131,11 +128,8 @@ protected virtual void AddToFileSystem(MockFileSystem fileSystem) { } protected virtual BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, - IConfigurationContext configurationContext) => - new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) - { - UrlPathPrefix = "/docs" - }; + IConfigurationContext configurationContext + ) => new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs" }; public virtual async ValueTask InitializeAsync() { diff --git a/tests/Elastic.Markdown.Tests/Inline/MathRoleTests.cs b/tests/Elastic.Markdown.Tests/Inline/MathRoleTests.cs index 9e6a6be21a..a15f7a9d75 100644 --- a/tests/Elastic.Markdown.Tests/Inline/MathRoleTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/MathRoleTests.cs @@ -6,25 +6,24 @@ namespace Elastic.Markdown.Tests.Inline; -public class MathRoleTests(ITestOutputHelper output) : InlineTest(output, +public class MathRoleTests(ITestOutputHelper output) : InlineTest( + output, """ Einstein's famous equation {math}`E = mc^2` relates energy and mass. """ ) { [Fact] - public void Render() => - Html.Should().Contain("E = mc^2") - .And.NotContain("{math}`E = mc^2`"); + public void Render() => Html.Should().Contain("E = mc^2").And.NotContain("{math}`E = mc^2`"); } -public class MathRoleEscapesContentTests(ITestOutputHelper output) : InlineTest(output, +public class MathRoleEscapesContentTests(ITestOutputHelper output) : InlineTest( + output, """ Compare {math}`a < b & b > c` for ordering. """ ) { [Fact] - public void Render() => - Html.Should().Contain("a < b & b > c"); + public void Render() => Html.Should().Contain("a < b & b > c"); } diff --git a/tests/Elastic.Markdown.Tests/Inline/SubstitutionInlineCodeTest.cs b/tests/Elastic.Markdown.Tests/Inline/SubstitutionInlineCodeTest.cs index fa6a55a3c4..06bf0605e4 100644 --- a/tests/Elastic.Markdown.Tests/Inline/SubstitutionInlineCodeTest.cs +++ b/tests/Elastic.Markdown.Tests/Inline/SubstitutionInlineCodeTest.cs @@ -6,8 +6,9 @@ namespace Elastic.Markdown.Tests.Inline; -public class SubstitutionInlineCodeTest(ITestOutputHelper output) : InlineTest(output, -""" +public class SubstitutionInlineCodeTest(ITestOutputHelper output) : InlineTest( + output, + """ --- sub: version: "8.15.0" diff --git a/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs b/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs index 60c53be3d0..205b318d95 100644 --- a/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs +++ b/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs @@ -8,8 +8,9 @@ namespace Elastic.Markdown.Tests.Inline; -public class SubstitutionTest(ITestOutputHelper output) : LeafTest(output, -""" +public class SubstitutionTest(ITestOutputHelper output) : LeafTest( + output, + """ --- sub: hello-world: "Hello World!" @@ -22,18 +23,12 @@ not a comment [Fact] public void ReplacesSubsFromFrontMatter() => - Html.Should().Contain( - """Hello World!""" - ).And.Contain( - """not a comment""" - ) - .And.NotContain( - """{{hello-world}}""" - ); + Html.Should().Contain("""Hello World!""").And.Contain("""not a comment""").And.NotContain("""{{hello-world}}"""); } -public class NeedsDoubleBrackets(ITestOutputHelper output) : InlineTest(output, -""" +public class NeedsDoubleBrackets(ITestOutputHelper output) : InlineTest( + output, + """ --- sub: hello-world: "Hello World!" @@ -51,24 +46,22 @@ not a {substitution} [Fact] public void PreservesSingleBracket() => - Html.Should().Contain( - """Hello World!""" - ).And.Contain( - """not a comment""" - ) - .And.NotContain( - """{{hello-world}}""" - ) - .And.Contain( // treated as attributes to the block - """{substitution}""" - ) - .And.Contain( - """{{valid-key}}""" - ); + Html.Should() + .Contain("""Hello World!""") + .And + .Contain("""not a comment""") + .And + .NotContain("""{{hello-world}}""") + .And + .Contain( // treated as attributes to the block + """{substitution}""") + .And + .Contain("""{{valid-key}}"""); } -public class SubstitutionInCodeBlockTest(ITestOutputHelper output) : BlockTest(output, -""" +public class SubstitutionInCodeBlockTest(ITestOutputHelper output) : BlockTest( + output, + """ --- sub: version: "7.17.0" @@ -88,35 +81,38 @@ cd elasticsearch-{{version}}/ <2> { [Fact] - public void ReplacesSubsInCode() => - Html.Should().Contain("7.17.0"); + public void ReplacesSubsInCode() => Html.Should().Contain("7.17.0"); } - -public class SupportsSubstitutionsFromDocSet(ITestOutputHelper output) : InlineTest(output, -""" +public class SupportsSubstitutionsFromDocSet(ITestOutputHelper output) : InlineTest( + output, + """ --- sub: hello-world: "Hello World!" --- The following should be subbed: {{hello-world}} The following should be subbed as well: {{global-var}} -""" -, new() { { "global-var", "A variable from docset.yml" } } +""", + new() { { "global-var", "A variable from docset.yml" } } ) { [Fact] public void EmitsGlobalVariable() => - Html.Should().Contain("Hello World!") - .And.NotContain("{{hello-world}}") - .And.Contain("A variable from docset.yml") - .And.NotContain("{{global-var}}"); + Html.Should() + .Contain("Hello World!") + .And + .NotContain("{{hello-world}}") + .And + .Contain("A variable from docset.yml") + .And + .NotContain("{{global-var}}"); } - -public class CanNotShadeGlobalVariables(ITestOutputHelper output) : InlineTest(output, -""" +public class CanNotShadeGlobalVariables(ITestOutputHelper output) : InlineTest( + output, + """ --- sub: hello-world: "Hello World!" @@ -126,24 +122,27 @@ public class CanNotShadeGlobalVariables(ITestOutputHelper output) : InlineTest(o The following should be subbed: {{hello-world}} The following should be subbed as well: {{hello-world}} -""" -, new() { { "hello-world", "A variable from docset.yml" } } +""", + new() { { "hello-world", "A variable from docset.yml" } } ) { [Fact] public void OnlySeesGlobalVariable() => - Html.Should().NotContain("Hello World!
    ") - .And.NotContain("{{hello-world}}") - .And.Contain("A variable from docset.yml"); + Html.Should().NotContain("Hello World!
    ").And.NotContain("{{hello-world}}").And.Contain("A variable from docset.yml"); [Fact] - public void HasError() => Collector.Diagnostics.Should().HaveCount(1) - .And.Contain(d => d.Message.Contains("{hello-world} can not be redeclared in front matter as its a global substitution")); + public void HasError() => + Collector.Diagnostics + .Should() + .HaveCount(1) + .And + .Contain(d => d.Message.Contains("{hello-world} can not be redeclared in front matter as its a global substitution")); } -public class ReplaceInHeader(ITestOutputHelper output) : InlineTest(output, -""" +public class ReplaceInHeader(ITestOutputHelper output) : InlineTest( + output, + """ --- sub: hello-world: "Hello World!" @@ -163,11 +162,11 @@ public void OnlySeesGlobalVariable() => [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); - } -public class ReplaceInImageAlt(ITestOutputHelper output) : InlineTest(output, -""" +public class ReplaceInImageAlt(ITestOutputHelper output) : InlineTest( + output, + """ --- sub: hello-world: Hello World @@ -181,13 +180,12 @@ public class ReplaceInImageAlt(ITestOutputHelper output) : InlineTest(output, { [Fact] - public void OnlySeesGlobalVariable() => - Html.Should().NotContain("alt=\"{{hello-world}}\"") - .And.Contain("alt=\"Hello World\""); + public void OnlySeesGlobalVariable() => Html.Should().NotContain("alt=\"{{hello-world}}\"").And.Contain("alt=\"Hello World\""); } -public class ReplaceInImageTitle(ITestOutputHelper output) : InlineTest(output, -""" +public class ReplaceInImageTitle(ITestOutputHelper output) : InlineTest( + output, + """ --- sub: hello-world: Hello World @@ -201,13 +199,12 @@ public class ReplaceInImageTitle(ITestOutputHelper output) : InlineTest(output, { [Fact] - public void OnlySeesGlobalVariable() => - Html.Should().NotContain("title=\"{{hello-world}}\"") - .And.Contain("title=\"Observability\""); + public void OnlySeesGlobalVariable() => Html.Should().NotContain("title=\"{{hello-world}}\"").And.Contain("title=\"Observability\""); } -public class MutationOperatorTest(ITestOutputHelper output) : InlineTest(output, -""" +public class MutationOperatorTest(ITestOutputHelper output) : InlineTest( + output, + """ --- sub: version: "9.0.4" @@ -232,24 +229,35 @@ public class MutationOperatorTest(ITestOutputHelper output) : InlineTest(output, public void MutationOperatorsWorkWithAndWithoutSpaces() { // Both versions with and without spaces should render the same way - Html.Should().Contain("Version: 9.0") - .And.Contain("Version with space: 9.0") - .And.Contain("Major only: 9") - .And.Contain("Major only with space: 9") - .And.Contain("Major.x: 9.x") - .And.Contain("Major.x with space: 9.x") - .And.Contain("Increase major: 10.0.0") - .And.Contain("Increase major with space: 10.0.0") - .And.Contain("Increase minor: 9.1.0") - .And.Contain("Increase minor with space: 9.1.0"); + Html.Should() + .Contain("Version: 9.0") + .And + .Contain("Version with space: 9.0") + .And + .Contain("Major only: 9") + .And + .Contain("Major only with space: 9") + .And + .Contain("Major.x: 9.x") + .And + .Contain("Major.x with space: 9.x") + .And + .Contain("Increase major: 10.0.0") + .And + .Contain("Increase major with space: 10.0.0") + .And + .Contain("Increase minor: 9.1.0") + .And + .Contain("Increase minor with space: 9.1.0"); } [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class MultipleMutationOperatorsTest(ITestOutputHelper output) : InlineTest(output, -""" +public class MultipleMutationOperatorsTest(ITestOutputHelper output) : InlineTest( + output, + """ --- sub: version: "9.0.4" @@ -269,18 +277,23 @@ public class MultipleMutationOperatorsTest(ITestOutputHelper output) : InlineTes public void MultipleMutationOperatorsWorkWithAndWithoutSpaces() { // Both versions with and without spaces should render the same way - Html.Should().Contain("Version: 9.0") - .And.Contain("Version with spaces: 9.0") - .And.Contain("Product: ELASTICSEARCH") - .And.Contain("Product with spaces: ELASTICSEARCH"); + Html.Should() + .Contain("Version: 9.0") + .And + .Contain("Version with spaces: 9.0") + .And + .Contain("Product: ELASTICSEARCH") + .And + .Contain("Product with spaces: ELASTICSEARCH"); } [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class MutationOperatorsInLinksTest(ITestOutputHelper output) : InlineTest(output, -""" +public class MutationOperatorsInLinksTest(ITestOutputHelper output) : InlineTest( + output, + """ --- sub: version: "9.0.4" @@ -301,27 +314,27 @@ [Link text with mutation and space]({{product | uc}} {{version | M.M}}) public void MutationOperatorsWorkInLinks() { // Check URL mutations - Html.Should().Contain("href=\"https://www.elastic.co/guide/en/elasticsearch/reference/9.0/index.html\"") - .And.NotContain("{{version|M.M}}") - .And.NotContain("{{version | M.M}}"); + Html.Should() + .Contain("href=\"https://www.elastic.co/guide/en/elasticsearch/reference/9.0/index.html\"") + .And + .NotContain("{{version|M.M}}") + .And + .NotContain("{{version | M.M}}"); // Check link text mutations - Html.Should().Contain("ELASTICSEARCH 9.0") - .And.NotContain("{{product|uc}}") - .And.NotContain("{{version|M.M}}"); + Html.Should().Contain("ELASTICSEARCH 9.0").And.NotContain("{{product|uc}}").And.NotContain("{{version|M.M}}"); // Check link text mutations with spaces - Html.Should().Contain("ELASTICSEARCH 9.0") - .And.NotContain("{{product | uc}}") - .And.NotContain("{{version | M.M}}"); + Html.Should().Contain("ELASTICSEARCH 9.0").And.NotContain("{{product | uc}}").And.NotContain("{{version | M.M}}"); } [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -public class MutationOperatorsInCodeBlocksTest(ITestOutputHelper output) : BlockTest(output, -""" +public class MutationOperatorsInCodeBlocksTest(ITestOutputHelper output) : BlockTest( + output, + """ --- sub: version: "9.0.4" @@ -341,13 +354,15 @@ public class MutationOperatorsInCodeBlocksTest(ITestOutputHelper output) : Block ) { [Fact] - public void MutationOperatorsWorkInCodeBlocks() - { - Html.Should().Contain("# Install Elasticsearch 9.0") - .And.Contain("elasticsearch-9.0-linux-x86_64.tar.gz") - .And.NotContain("{{version|M.M}}") - .And.NotContain("{{version | M.M}}"); - } + public void MutationOperatorsWorkInCodeBlocks() => + Html.Should() + .Contain("# Install Elasticsearch 9.0") + .And + .Contain("elasticsearch-9.0-linux-x86_64.tar.gz") + .And + .NotContain("{{version|M.M}}") + .And + .NotContain("{{version | M.M}}"); [Fact] public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); diff --git a/tests/Elastic.Markdown.Tests/MissingTocFileTests.cs b/tests/Elastic.Markdown.Tests/MissingTocFileTests.cs index ba81aeb26f..67ced6def2 100644 --- a/tests/Elastic.Markdown.Tests/MissingTocFileTests.cs +++ b/tests/Elastic.Markdown.Tests/MissingTocFileTests.cs @@ -16,22 +16,28 @@ public class MissingTocFileTests(ITestOutputHelper output) public void TocReferencesMissingFile_DoesNotThrow_AndEmitsClearError() { var logger = new TestLoggerFactory(output); - var fileSystem = new MockFileSystem(new Dictionary - { - { "docs/docset.yml", new MockFileData(""" + var fileSystem = new MockFileSystem( + new Dictionary + { + { + "docs/docset.yml", + new MockFileData(""" project: test toc: - file: missing.md - """) }, - // A markdown file that exists on disk but is not referenced by the TOC. - // It keeps the documentation set non-empty so construction reaches the - // navigation traversal (VisitNavigation + BuildNavigationLookups) that - // previously dereferenced the null Index sentinel and crashed. - { "docs/present.md", new MockFileData("# Present") } - }, new MockFileSystemOptions - { - CurrentDirectory = Paths.WorkingDirectoryRoot.FullName - }); + """) + }, + // A markdown file that exists on disk but is not referenced by the TOC. + // It keeps the documentation set non-empty so construction reaches the + // navigation traversal (VisitNavigation + BuildNavigationLookups) that + // previously dereferenced the null Index sentinel and crashed. + { + "docs/present.md", + new MockFileData("# Present") + } + }, + new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName } + ); var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); @@ -42,9 +48,8 @@ public void TocReferencesMissingFile_DoesNotThrow_AndEmitsClearError() act.Should().NotThrow("a missing toc file must surface a validation error, not crash the build"); collector.Errors.Should().BeGreaterThan(0); - collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && - d.Message.Contains("missing.md") && - d.Message.Contains("does not exist")); + collector.Diagnostics + .Should() + .Contain(d => d.Severity == Severity.Error && d.Message.Contains("missing.md") && d.Message.Contains("does not exist")); } } diff --git a/tests/Elastic.Markdown.Tests/MockFileSystemExtensions.cs b/tests/Elastic.Markdown.Tests/MockFileSystemExtensions.cs index b98472d614..1f52ab62c1 100644 --- a/tests/Elastic.Markdown.Tests/MockFileSystemExtensions.cs +++ b/tests/Elastic.Markdown.Tests/MockFileSystemExtensions.cs @@ -14,7 +14,8 @@ public static void GenerateDocSetYaml( IDirectoryInfo root, Dictionary? globalVariables = null, IReadOnlyList? products = null, - string? extraYaml = null) + string? extraYaml = null + ) { // language=yaml var yaml = new StringWriter(); @@ -31,8 +32,7 @@ public static void GenerateDocSetYaml( yaml.WriteLine(" - docs-content"); yaml.WriteLine(" - kibana"); yaml.WriteLine("toc:"); - var markdownFiles = fileSystem.Directory - .EnumerateFiles(root.FullName, "*.md", SearchOption.AllDirectories); + var markdownFiles = fileSystem.Directory.EnumerateFiles(root.FullName, "*.md", SearchOption.AllDirectories); foreach (var markdownFile in markdownFiles) { if (markdownFile.Contains($"{Path.DirectorySeparatorChar}_snippets{Path.DirectorySeparatorChar}")) diff --git a/tests/Elastic.Markdown.Tests/Mover/MoverTests.cs b/tests/Elastic.Markdown.Tests/Mover/MoverTests.cs index 682c5815cf..32258549b1 100644 --- a/tests/Elastic.Markdown.Tests/Mover/MoverTests.cs +++ b/tests/Elastic.Markdown.Tests/Mover/MoverTests.cs @@ -6,10 +6,8 @@ using Elastic.Documentation.Refactor; using Elastic.Markdown.Tests.DocSet; - namespace Elastic.Markdown.Tests.Mover; - public class MoverTests(ITestOutputHelper output) : NavigationTestsBase(output) { [Fact] @@ -27,7 +25,6 @@ public async Task RelativeLinks() var linkModifications = mover.LinkModifications[changeSet]; linkModifications.Should().HaveCount(3); - Path.GetRelativePath(".", linkModifications[0].SourceFile).Should().Be(Path.Join("mover", "first-page.md")); linkModifications[0].OldLink.Should().Be("[Link to second page](second-page.md)"); linkModifications[0].NewLink.Should().Be("[Link to second page](../mover/second-page.md)"); diff --git a/tests/Elastic.Markdown.Tests/OutputDirectoryTests.cs b/tests/Elastic.Markdown.Tests/OutputDirectoryTests.cs index fab4e2cedc..bb2c9f7d09 100644 --- a/tests/Elastic.Markdown.Tests/OutputDirectoryTests.cs +++ b/tests/Elastic.Markdown.Tests/OutputDirectoryTests.cs @@ -16,20 +16,22 @@ public class OutputDirectoryTests(ITestOutputHelper output) public async Task CreatesDefaultOutputDirectory() { var logger = new TestLoggerFactory(output); - var fileSystem = new MockFileSystem(new Dictionary - { - { "docs/docset.yml", - //language=yaml - new MockFileData(""" + var fileSystem = new MockFileSystem( + new Dictionary + { + { + "docs/docset.yml", + //language=yaml + new MockFileData(""" project: test toc: - file: index.md -""") }, - { "docs/index.md", new MockFileData("test") } - }, new MockFileSystemOptions - { - CurrentDirectory = Paths.WorkingDirectoryRoot.FullName - }); +""") + }, + { "docs/index.md", new MockFileData("test") } + }, + new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName } + ); await using var collector = new DiagnosticsCollector([]).StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var context = new BuildContext(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext); @@ -47,22 +49,24 @@ public async Task CreatesDefaultOutputDirectory() public void FilesWithSnippetsInNameNotTreatedAsSnippets() { var logger = new TestLoggerFactory(output); - var fileSystem = new MockFileSystem(new Dictionary - { - { "docs/docset.yml", - //language=yaml - new MockFileData(""" + var fileSystem = new MockFileSystem( + new Dictionary + { + { + "docs/docset.yml", + //language=yaml + new MockFileData(""" project: test toc: - file: index.md - file: top_snippets.md -""") }, - { "docs/index.md", new MockFileData("# Test") }, - { "docs/top_snippets.md", new MockFileData("# Top Snippets") } - }, new MockFileSystemOptions - { - CurrentDirectory = Paths.WorkingDirectoryRoot.FullName - }); +""") + }, + { "docs/index.md", new MockFileData("# Test") }, + { "docs/top_snippets.md", new MockFileData("# Top Snippets") } + }, + new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName } + ); var collector = new TestDiagnosticsCollector(output); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var context = new BuildContext(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext); @@ -89,258 +93,204 @@ public void OutputFileValidationInvalidNames(string fileName) } public static TheoryData ValidFileNames => - [ - "test.md", - "file.txt", - "index.html", - "readme.rst", - - // With numbers - "test123.md", - "123test.md", - "file2.md", - "99bottles.md", - - // With underscores - "test_file.md", - "my_long_file_name.md", - "_leading_underscore.md", - "trailing_underscore_.md", - - // With hyphens - "test-file.md", - "my-long-file-name.md", - "trailing-hyphen-.md", - - // Combined underscores and hyphens - "test_file-name.md", - "my-file_name.md", - - // With dots in filename (before extension) - "test.config.md", - "file.test.backup.md", - "v1.0.0.md", - - // With spaces (allowed per regex) - "test file.md", - "my document.md", - - // Paths with all lowercase directories - "path/to/file.md", - "deep/nested/path/to/file.md", - "folder/subfolder/document.md", - - // Paths with numbers - "path123/file.md", - "v1/docs/guide.md", - - // Paths with underscores and hyphens - "my_folder/file.md", - "my-folder/file.md", - "path_to/sub-folder/file.md", - - // SVG files exception (even with uppercase - per the .EndsWith checks) - "image.svg", - "Icon.svg", - "LOGO.svg", - "path/to/Image.svg", - - // PNG files exception - "image.png", - "Screenshot.png", - "IMAGE.png", - "path/to/Logo.png", - - // GIF files exception - "animation.gif", - "Loading.gif", - "SPINNER.gif", - - // ESQL snippets exception (prior art) - "reference/query-languages/esql/_snippets/functions/examples/cbrt.md", - "reference/query-languages/esql/_snippets/anything/here/File.md", - "reference/query-languages/esql/_snippets/UPPERCASE.md", - - // Hardcoded exceptions - "reference/security/prebuilt-rules/audit_policies/windows/README.md", - "extend/integrations/developer-workflow-fleet-UI.md", - "reference/elasticsearch/clients/ruby/Helpers.md", - "explore-analyze/ai-features/llm-guides/connect-to-vLLM.md", - - // Plus sign in path (e.g., semantic version with build metadata) - "release-notes/_snippets/9.2.0+build202510300150/index.md", - "test+file.md", - "c++.md" - ]; + [ + "test.md", + "file.txt", + "index.html", + "readme.rst", + // With numbers + "test123.md", + "123test.md", + "file2.md", + "99bottles.md", + // With underscores + "test_file.md", + "my_long_file_name.md", + "_leading_underscore.md", + "trailing_underscore_.md", + // With hyphens + "test-file.md", + "my-long-file-name.md", + "trailing-hyphen-.md", + // Combined underscores and hyphens + "test_file-name.md", + "my-file_name.md", + // With dots in filename (before extension) + "test.config.md", + "file.test.backup.md", + "v1.0.0.md", + // With spaces (allowed per regex) + "test file.md", + "my document.md", + // Paths with all lowercase directories + "path/to/file.md", + "deep/nested/path/to/file.md", + "folder/subfolder/document.md", + // Paths with numbers + "path123/file.md", + "v1/docs/guide.md", + // Paths with underscores and hyphens + "my_folder/file.md", + "my-folder/file.md", + "path_to/sub-folder/file.md", + // SVG files exception (even with uppercase - per the .EndsWith checks) + "image.svg", + "Icon.svg", + "LOGO.svg", + "path/to/Image.svg", + // PNG files exception + "image.png", + "Screenshot.png", + "IMAGE.png", + "path/to/Logo.png", + // GIF files exception + "animation.gif", + "Loading.gif", + "SPINNER.gif", + // ESQL snippets exception (prior art) + "reference/query-languages/esql/_snippets/functions/examples/cbrt.md", + "reference/query-languages/esql/_snippets/anything/here/File.md", + "reference/query-languages/esql/_snippets/UPPERCASE.md", + // Hardcoded exceptions + "reference/security/prebuilt-rules/audit_policies/windows/README.md", + "extend/integrations/developer-workflow-fleet-UI.md", + "reference/elasticsearch/clients/ruby/Helpers.md", + "explore-analyze/ai-features/llm-guides/connect-to-vLLM.md", + // Plus sign in path (e.g., semantic version with build metadata) + "release-notes/_snippets/9.2.0+build202510300150/index.md", + "test+file.md", + "c++.md" + ]; public static TheoryData InvalidFileNames => - [ - "Test.md", - "FILE.md", - "MyFile.md", - "testFile.md", - "README.md", - - // Uppercase in extension - "test.MD", - "test.Md", - "file.TXT", - "document.Html", - - // Uppercase in directory path - "Path/file.md", - "path/To/file.md", - "FOLDER/file.md", - "docs/MyFolder/file.md", - - // Filenames starting with invalid characters (must start with [a-z0-9_]) - "-leading-hyphen.md", - "-file.md", - ".hidden.md", - " leading-space.md", - "path/to/-invalid.md", - "path/to/.hidden.md", - "path/to/ space.md", - - // Special characters - parentheses - "test(1).md", - "file (copy).md", - "document(v2).md", - - // Special characters - square brackets - "test[1].md", - "file[copy].md", - - // Special characters - curly braces - "test{1}.md", - - // Special characters - exclamation mark - "test!.md", - "important!file.md", - - // Special characters - at sign - "test@file.md", - "user@domain.md", - - // Special characters - hash - "test#1.md", - "file#.md", - - // Special characters - dollar sign - "test$file.md", - "price$.md", - - // Special characters - percent - "test%file.md", - "100%done.md", - - // Special characters - caret - "test^file.md", - - // Special characters - ampersand - "test&file.md", - "this&that.md", - - // Special characters - asterisk - "test*file.md", - "*.md", - - // Special characters - equals sign - "test=file.md", - - // Special characters - pipe - "test|file.md", - - // Special characters - less than / greater than - "test.md", - - // Special characters - colon - "test:file.md", - - // Special characters - semicolon - "test;file.md", - - // Special characters - single quote - "test'file.md", - "it's.md", - - // Special characters - double quote - "test\"file.md", - - // Special characters - backtick - "test`file.md", - - // Special characters - tilde - "test~file.md", - "~temp.md", - - // Special characters - comma - "test,file.md", - "a,b,c.md", - - // Special characters - question mark - "test?.md", - "what?.md", - - // No extension - "testfile", - "README", - "Makefile", - - // Just extension - ".md", - ".txt", - - // Empty extension - "test.", - - // Double extension edge cases with uppercase - "test.Config.md", - "file.Test.md", - - // Non-ASCII characters - accented - "tëst.md", - "café.md", - "naïve.md", - "résumé.md", - - // Non-ASCII characters - other alphabets - "тест.md", - "测试.md", - "テスト.md", - - // Non-ASCII characters - symbols - "test™.md", - "file©.md", - - // Empty string - "", - - // Whitespace only - " ", - - // Extension only variations - "..md", - - // Numbers in extension (if we expect only letters) - "test.md5", - "file.mp3", - "video.mp4", - - // CamelCase variations - "camelCase.md", - "PascalCase.md", - "mixedCASE.md", - - // Acronyms - "API.md", - "HTTP.md", - "XMLParser.md", - - // Common problematic filenames - "CHANGELOG.md", - "LICENSE.md", - "CONTRIBUTING.md", - "TODO.md" - ]; + [ + "Test.md", + "FILE.md", + "MyFile.md", + "testFile.md", + "README.md", + // Uppercase in extension + "test.MD", + "test.Md", + "file.TXT", + "document.Html", + // Uppercase in directory path + "Path/file.md", + "path/To/file.md", + "FOLDER/file.md", + "docs/MyFolder/file.md", + // Filenames starting with invalid characters (must start with [a-z0-9_]) + "-leading-hyphen.md", + "-file.md", + ".hidden.md", + " leading-space.md", + "path/to/-invalid.md", + "path/to/.hidden.md", + "path/to/ space.md", + // Special characters - parentheses + "test(1).md", + "file (copy).md", + "document(v2).md", + // Special characters - square brackets + "test[1].md", + "file[copy].md", + // Special characters - curly braces + "test{1}.md", + // Special characters - exclamation mark + "test!.md", + "important!file.md", + // Special characters - at sign + "test@file.md", + "user@domain.md", + // Special characters - hash + "test#1.md", + "file#.md", + // Special characters - dollar sign + "test$file.md", + "price$.md", + // Special characters - percent + "test%file.md", + "100%done.md", + // Special characters - caret + "test^file.md", + // Special characters - ampersand + "test&file.md", + "this&that.md", + // Special characters - asterisk + "test*file.md", + "*.md", + // Special characters - equals sign + "test=file.md", + // Special characters - pipe + "test|file.md", + // Special characters - less than / greater than + "test.md", + // Special characters - colon + "test:file.md", + // Special characters - semicolon + "test;file.md", + // Special characters - single quote + "test'file.md", + "it's.md", + // Special characters - double quote + "test\"file.md", + // Special characters - backtick + "test`file.md", + // Special characters - tilde + "test~file.md", + "~temp.md", + // Special characters - comma + "test,file.md", + "a,b,c.md", + // Special characters - question mark + "test?.md", + "what?.md", + // No extension + "testfile", + "README", + "Makefile", + // Just extension + ".md", + ".txt", + // Empty extension + "test.", + // Double extension edge cases with uppercase + "test.Config.md", + "file.Test.md", + // Non-ASCII characters - accented + "tëst.md", + "café.md", + "naïve.md", + "résumé.md", + // Non-ASCII characters - other alphabets + "тест.md", + "测试.md", + "テスト.md", + // Non-ASCII characters - symbols + "test™.md", + "file©.md", + // Empty string + "", + // Whitespace only + " ", + // Extension only variations + "..md", + // Numbers in extension (if we expect only letters) + "test.md5", + "file.mp3", + "video.mp4", + // CamelCase variations + "camelCase.md", + "PascalCase.md", + "mixedCASE.md", + // Acronyms + "API.md", + "HTTP.md", + "XMLParser.md", + // Common problematic filenames + "CHANGELOG.md", + "LICENSE.md", + "CONTRIBUTING.md", + "TODO.md" + ]; } diff --git a/tests/Elastic.Markdown.Tests/PrettyHtmlExtensions.cs b/tests/Elastic.Markdown.Tests/PrettyHtmlExtensions.cs index b24e21482d..0e9346253a 100644 --- a/tests/Elastic.Markdown.Tests/PrettyHtmlExtensions.cs +++ b/tests/Elastic.Markdown.Tests/PrettyHtmlExtensions.cs @@ -28,26 +28,24 @@ public static string PrettyHtml([LanguageInjection("html")] this string html, bo if (sanitize) { var links = element.QuerySelectorAll("a"); - links - .ForEach(l => - { - l.RemoveAttribute("hx-get"); - l.RemoveAttribute("hx-select-oob"); - l.RemoveAttribute("hx-swap"); - l.RemoveAttribute("hx-indicator"); - l.RemoveAttribute("hx-push-url"); - l.RemoveAttribute("preload"); - }); + links.ForEach(l => + { + l.RemoveAttribute("hx-get"); + l.RemoveAttribute("hx-select-oob"); + l.RemoveAttribute("hx-swap"); + l.RemoveAttribute("hx-indicator"); + l.RemoveAttribute("hx-push-url"); + l.RemoveAttribute("preload"); + }); } using var sw = new StringWriter(); var formatter = new PrettyMarkupFormatter(); - element.Children - .ForEach(c => - { - // ReSharper disable once AccessToDisposedClosure - c.ToHtml(sw, formatter); - }); + element.Children.ForEach(c => + { + // ReSharper disable once AccessToDisposedClosure + c.ToHtml(sw, formatter); + }); return sw.ToString().TrimStart('\n'); } @@ -60,11 +58,7 @@ public static void ShouldBeHtml( expected = expected.Trim('\n').PrettyHtml(sanitize); actual = actual.Trim('\n').PrettyHtml(sanitize); - var diff = DiffBuilder - .Compare(actual) - .WithTest(expected) - .Build() - .ToArray(); + var diff = DiffBuilder.Compare(actual).WithTest(expected).Build().ToArray(); if (diff.Length == 0) return; @@ -95,9 +89,9 @@ public static string CreateDiff(this string actual, string expected, bool saniti actual = actual.Trim('\n').PrettyHtml(sanitize); var diffLines = InlineDiffBuilder.Diff(expected, actual).Lines; - var mutatedCount = - diffLines - .Count(l => l.Type switch + var mutatedCount = diffLines.Count( + l => + l.Type switch { ChangeType.Unchanged => false, ChangeType.Deleted => true, @@ -105,7 +99,8 @@ public static string CreateDiff(this string actual, string expected, bool saniti ChangeType.Imaginary => false, ChangeType.Modified => true, _ => false - }); + } + ); if (mutatedCount == 0) return string.Empty; @@ -122,28 +117,27 @@ public static string CreateDiff(this string actual, string expected, bool saniti } using var sw = new StringWriter(); - diffLines - .ForEach(l => + diffLines.ForEach(l => + { + switch (l.Type) { - switch (l.Type) - { - case ChangeType.Unchanged: - sw.WriteLine(l.Text); - break; - case ChangeType.Deleted: - sw.WriteLine("- " + l.Text); - break; - case ChangeType.Inserted: - sw.WriteLine("+ " + l.Text); - break; - case ChangeType.Imaginary: - sw.WriteLine("? " + l.Text); - break; - case ChangeType.Modified: - sw.WriteLine("+ " + l.Text); - break; - } - }); + case ChangeType.Unchanged: + sw.WriteLine(l.Text); + break; + case ChangeType.Deleted: + sw.WriteLine("- " + l.Text); + break; + case ChangeType.Inserted: + sw.WriteLine("+ " + l.Text); + break; + case ChangeType.Imaginary: + sw.WriteLine("? " + l.Text); + break; + case ChangeType.Modified: + sw.WriteLine("+ " + l.Text); + break; + } + }); return sw.ToString(); } diff --git a/tests/Elastic.Markdown.Tests/RootIndexValidationTests.cs b/tests/Elastic.Markdown.Tests/RootIndexValidationTests.cs index 20cf1f4b74..ec985759b9 100644 --- a/tests/Elastic.Markdown.Tests/RootIndexValidationTests.cs +++ b/tests/Elastic.Markdown.Tests/RootIndexValidationTests.cs @@ -17,19 +17,24 @@ public class RootIndexValidationTests(ITestOutputHelper output) public void InternalRegistry_MissingIndexMd_EmitsError() { var logger = new TestLoggerFactory(output); - var fileSystem = new MockFileSystem(new Dictionary - { - { "docs/docset.yml", new MockFileData(""" + var fileSystem = new MockFileSystem( + new Dictionary + { + { + "docs/docset.yml", + new MockFileData( + """ project: test registry: internal toc: - file: getting-started.md - """) }, - { "docs/getting-started.md", new MockFileData("# Getting started") } - }, new MockFileSystemOptions - { - CurrentDirectory = Paths.WorkingDirectoryRoot.FullName - }); + """ + ) + }, + { "docs/getting-started.md", new MockFileData("# Getting started") } + }, + new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName } + ); var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); @@ -37,67 +42,69 @@ public void InternalRegistry_MissingIndexMd_EmitsError() _ = new DocumentationSet(context, logger, new TestCrossLinkResolver()); collector.Errors.Should().BeGreaterThan(0); - collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && - d.Message.Contains("index.md")); + collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("index.md")); } [Fact] public void InternalRegistry_WithIndexMd_NoError() { var logger = new TestLoggerFactory(output); - var fileSystem = new MockFileSystem(new Dictionary - { - { "docs/docset.yml", new MockFileData(""" + var fileSystem = new MockFileSystem( + new Dictionary + { + { + "docs/docset.yml", + new MockFileData( + """ project: test registry: internal toc: - file: index.md - file: getting-started.md - """) }, - { "docs/index.md", new MockFileData("# Home") }, - { "docs/getting-started.md", new MockFileData("# Getting started") } - }, new MockFileSystemOptions - { - CurrentDirectory = Paths.WorkingDirectoryRoot.FullName - }); + """ + ) + }, + { "docs/index.md", new MockFileData("# Home") }, + { "docs/getting-started.md", new MockFileData("# Getting started") } + }, + new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName } + ); var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var context = new BuildContext(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext); _ = new DocumentationSet(context, logger, new TestCrossLinkResolver()); - collector.Diagnostics - .Where(d => d.Severity == Severity.Error && d.Message.Contains("index.md")) - .Should() - .BeEmpty(); + collector.Diagnostics.Where(d => d.Severity == Severity.Error && d.Message.Contains("index.md")).Should().BeEmpty(); } [Fact] public void PublicRegistry_MissingIndexMd_NoError() { var logger = new TestLoggerFactory(output); - var fileSystem = new MockFileSystem(new Dictionary - { - { "docs/docset.yml", new MockFileData(""" + var fileSystem = new MockFileSystem( + new Dictionary + { + { + "docs/docset.yml", + new MockFileData( + """ project: test toc: - file: getting-started.md - """) }, - { "docs/getting-started.md", new MockFileData("# Getting started") } - }, new MockFileSystemOptions - { - CurrentDirectory = Paths.WorkingDirectoryRoot.FullName - }); + """ + ) + }, + { "docs/getting-started.md", new MockFileData("# Getting started") } + }, + new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName } + ); var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var context = new BuildContext(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext); _ = new DocumentationSet(context, logger, new TestCrossLinkResolver()); - collector.Diagnostics - .Where(d => d.Severity == Severity.Error && d.Message.Contains("index.md")) - .Should() - .BeEmpty(); + collector.Diagnostics.Where(d => d.Severity == Severity.Error && d.Message.Contains("index.md")).Should().BeEmpty(); } } diff --git a/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs b/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs index 9f8c866202..4637fae044 100644 --- a/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs +++ b/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs @@ -26,10 +26,7 @@ public void SerializeDocumentWithStackAppliesToProducesCorrectJson() Path = "/test/page", Title = "Test Page", SearchTitle = "Test Page", - Applies = new ApplicableTo - { - Stack = AppliesCollection.GenerallyAvailable - }.ToAppliesTo() + Applies = new ApplicableTo { Stack = AppliesCollection.GenerallyAvailable }.ToAppliesTo() }; var json = JsonSerializer.Serialize(doc, _options); @@ -60,14 +57,16 @@ public void SerializeDocumentWithDeploymentAppliesToProducesCorrectJson() Path = "/test/deployment", Title = "Deployment Test", SearchTitle = "Deployment Test", - Applies = new ApplicableTo - { - Deployment = new DeploymentApplicability + Applies = + new ApplicableTo { - Ess = AppliesCollection.GenerallyAvailable, - Ece = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"3.5.0" }]) - } - }.ToAppliesTo() + Deployment = new DeploymentApplicability + { + Ess = AppliesCollection.GenerallyAvailable, + Ece = + new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"3.5.0" }]) + } + }.ToAppliesTo() }; var json = JsonSerializer.Serialize(doc, _options); @@ -102,14 +101,21 @@ public void SerializeDocumentWithServerlessAppliesToProducesCorrectJson() Path = "/test/serverless", Title = "Serverless Test", SearchTitle = "Serverless Test", - Applies = new ApplicableTo - { - Serverless = new ServerlessProjectApplicability + Applies = + new ApplicableTo { - Elasticsearch = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"8.0.0" }]), - Security = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (VersionSpec)"1.0.0" }]) - } - }.ToAppliesTo() + Serverless = new ServerlessProjectApplicability + { + Elasticsearch = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"8.0.0" } + ]), + Security = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (VersionSpec)"1.0.0" } + ]) + } + }.ToAppliesTo() }; var json = JsonSerializer.Serialize(doc, _options); @@ -144,10 +150,12 @@ public void SerializeDocumentWithProductAppliesToProducesCorrectJson() Path = "/test/product", Title = "Product Test", SearchTitle = "Product Test", - Applies = new ApplicableTo - { - Product = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"2.0.0" }]) - }.ToAppliesTo() + Applies = + new ApplicableTo + { + Product = + new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"2.0.0" }]) + }.ToAppliesTo() }; var json = JsonSerializer.Serialize(doc, _options); @@ -174,14 +182,21 @@ public void SerializeDocumentWithProductApplicabilityProducesCorrectJson() Path = "/test/apm", Title = "APM Test", SearchTitle = "APM Test", - Applies = new ApplicableTo - { - ProductApplicability = new ProductApplicability + Applies = + new ApplicableTo { - ApmAgentDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"1.5.0" }]), - ApmAgentNode = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (VersionSpec)"2.0.0" }]) - } - }.ToAppliesTo() + ProductApplicability = new ProductApplicability + { + ApmAgentDotnet = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"1.5.0" } + ]), + ApmAgentNode = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (VersionSpec)"2.0.0" } + ]) + } + }.ToAppliesTo() }; var json = JsonSerializer.Serialize(doc, _options); @@ -216,18 +231,16 @@ public void SerializeDocumentWithComplexAppliesToProducesCorrectJson() Path = "/test/complex", Title = "Complex Test", SearchTitle = "Complex Test", - Applies = new ApplicableTo - { - Stack = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"8.0.0" }]), - Deployment = new DeploymentApplicability + Applies = + new ApplicableTo { - Ess = AppliesCollection.GenerallyAvailable - }, - Serverless = new ServerlessProjectApplicability - { - Elasticsearch = AppliesCollection.GenerallyAvailable - } - }.ToAppliesTo() + Stack = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"8.0.0" } + ]), + Deployment = new DeploymentApplicability { Ess = AppliesCollection.GenerallyAvailable }, + Serverless = new ServerlessProjectApplicability { Elasticsearch = AppliesCollection.GenerallyAvailable } + }.ToAppliesTo() }; var json = JsonSerializer.Serialize(doc, _options); @@ -297,7 +310,10 @@ public void RoundTripDocumentWithAppliesToPreservesData() { var originalApplies = new ApplicableTo { - Stack = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"8.5.0" }]), + Stack = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"8.5.0" } + ]), Deployment = new DeploymentApplicability { Ess = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"8.6.0" }]) @@ -331,7 +347,9 @@ public void RoundTripDocumentWithAppliesToPreservesData() deserialized.Applies.Should().NotBeNull(); deserialized.Applies.Should().HaveCount(2); deserialized.Applies.Should().Contain(e => e.Type == "stack" && e.Lifecycle == "ga" && e.Version == "8.5+"); - deserialized.Applies.Should().Contain(e => e.Type == "deployment" && e.SubType == "ess" && e.Lifecycle == "beta" && e.Version == "8.6+"); + deserialized.Applies + .Should() + .Contain(e => e.Type == "deployment" && e.SubType == "ess" && e.Lifecycle == "beta" && e.Version == "8.6+"); deserialized.ContentLastUpdated.Should().Be(original.ContentLastUpdated); deserialized.ContentBodyHash.Should().Be(original.ContentBodyHash); deserialized.ContentType.Should().Be(original.ContentType); @@ -342,13 +360,7 @@ public void SerializeDocumentationDocument_IncludesContentType_MatchingType() { foreach (var type in new[] { "doc", "api" }) { - var doc = new DocumentationDocument - { - ContentType = type, - Path = $"/test/{type}", - Title = "T", - SearchTitle = "T" - }; + var doc = new DocumentationDocument { ContentType = type, Path = $"/test/{type}", Title = "T", SearchTitle = "T" }; var json = JsonSerializer.Serialize(doc, _options); using var parsed = JsonDocument.Parse(json); @@ -362,7 +374,8 @@ public void SerializeDocumentationDocument_IncludesContentType_MatchingType() [Fact] public void ContentType_FromJson_Overrides_Type() { - var json = """ + var json = + """ { "title": "Legacy", "search_title": "Legacy", @@ -391,15 +404,16 @@ public void SerializeDocumentWithMultipleApplicabilitiesPerTypeProducesMultipleA Path = "/test/multiple", Title = "Multiple Test", SearchTitle = "Multiple Test", - Applies = new ApplicableTo - { - Stack = new AppliesCollection( - [ - new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"8.0.0" }, - new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"7.17.0" }, - new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (VersionSpec)"7.0.0" } - ]) - }.ToAppliesTo() + Applies = + new ApplicableTo + { + Stack = + new AppliesCollection([ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"8.0.0" }, + new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"7.17.0" }, + new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (VersionSpec)"7.0.0" } + ]) + }.ToAppliesTo() }; var json = JsonSerializer.Serialize(doc, _options); @@ -434,20 +448,17 @@ public void SerializeDocument_IncludesSourceUrlAsSnakeCaseKeyword() var json = JsonSerializer.Serialize(doc, _options); using var jsonDoc = JsonDocument.Parse(json); - jsonDoc.RootElement.GetProperty("source_url").GetString() - .Should().Be("https://github.com/elastic/docs-content/blob/main/docs/some-page.md"); + jsonDoc.RootElement + .GetProperty("source_url") + .GetString() + .Should() + .Be("https://github.com/elastic/docs-content/blob/main/docs/some-page.md"); } [Fact] public void SerializeDocument_OmitsSourceUrlWhenNull() { - var doc = new DocumentationDocument - { - Path = "/docs/some-page", - Title = "Some Page", - SearchTitle = "Some Page", - SourceUrl = null - }; + var doc = new DocumentationDocument { Path = "/docs/some-page", Title = "Some Page", SearchTitle = "Some Page", SourceUrl = null }; var json = JsonSerializer.Serialize(doc, _options); using var jsonDoc = JsonDocument.Parse(json); diff --git a/tests/Elastic.Markdown.Tests/Search/NavigationEnrichmentTests.cs b/tests/Elastic.Markdown.Tests/Search/NavigationEnrichmentTests.cs index 029c7eace3..43f83fa4c7 100644 --- a/tests/Elastic.Markdown.Tests/Search/NavigationEnrichmentTests.cs +++ b/tests/Elastic.Markdown.Tests/Search/NavigationEnrichmentTests.cs @@ -21,12 +21,8 @@ public class NavigationEnrichmentTests { private const int PenaltyDefault = 50; - private static DocumentationDocument NewDoc() => new() - { - Path = "/docs/reference/some-page", - Title = "Some Page", - SearchTitle = "Some Page" - }; + private static DocumentationDocument NewDoc() => + new() { Path = "/docs/reference/some-page", Title = "Some Page", SearchTitle = "Some Page" }; [Fact] public void LandingPageRoot_GetsLowDepthAndNonDefaultToc() @@ -45,11 +41,19 @@ public void LandingPageRoot_GetsLowDepthAndNonDefaultToc() [Fact] public void ReleaseNotesRoot_IsDampenedRelativeToOtherRootsAtTheSameDepth() { - var releaseNotesRoot = new FakeRootNavigationItem { NavigationTitle = "Release Notes", Parent = new FakeNodeNavigationItem { NavigationTitle = "Parent" } }; + var releaseNotesRoot = new FakeRootNavigationItem + { + NavigationTitle = "Release Notes", + Parent = new FakeNodeNavigationItem { NavigationTitle = "Parent" } + }; var releaseNotesDoc = NewDoc(); ElasticsearchMarkdownExporter.CommonEnrichments(releaseNotesDoc, releaseNotesRoot); - var otherRoot = new FakeRootNavigationItem { NavigationTitle = "Reference", Parent = new FakeNodeNavigationItem { NavigationTitle = "Parent" } }; + var otherRoot = new FakeRootNavigationItem + { + NavigationTitle = "Reference", + Parent = new FakeNodeNavigationItem { NavigationTitle = "Parent" } + }; var otherDoc = NewDoc(); ElasticsearchMarkdownExporter.CommonEnrichments(otherDoc, otherRoot); diff --git a/tests/Elastic.Markdown.Tests/SettingsInclusion/IncludeTests.cs b/tests/Elastic.Markdown.Tests/SettingsInclusion/IncludeTests.cs index 7378e6d4c4..f21f846920 100644 --- a/tests/Elastic.Markdown.Tests/SettingsInclusion/IncludeTests.cs +++ b/tests/Elastic.Markdown.Tests/SettingsInclusion/IncludeTests.cs @@ -11,15 +11,15 @@ namespace Elastic.Markdown.Tests.SettingsInclusion; -public class IncludeTests(ITestOutputHelper output) : DirectiveTest(output, -$$""" +public class IncludeTests(ITestOutputHelper output) : DirectiveTest( + output, + $$""" :::{settings} /{{SettingsPath.Replace("docs/", "")}} ::: """ ) { - private static readonly string SettingsPath = - "docs/syntax/kibana-alerting-action-settings.yml"; + private static readonly string SettingsPath = "docs/syntax/kibana-alerting-action-settings.yml"; protected override void AddToFileSystem(MockFileSystem fileSystem) { @@ -36,12 +36,11 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); [Fact] - public void IncludesInclusionHtml() => - Html.Should() - .Contain("xpack.encryptedSavedObjects.encryptionKey"); + public void IncludesInclusionHtml() => Html.Should().Contain("xpack.encryptedSavedObjects.encryptionKey"); } -public class RandomFileEmitsAnError(ITestOutputHelper output) : DirectiveTest(output, -""" +public class RandomFileEmitsAnError(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{settings} _snippets/test.md ::: """ @@ -60,13 +59,13 @@ public void EmitsError() Collector.Diagnostics.Should().NotBeNullOrEmpty().And.HaveCount(1); Collector.Diagnostics.Should().OnlyContain(d => d.Severity == Severity.Error); Collector.Diagnostics.FirstOrDefault().File.Should().NotEndWith("test.md"); - Collector.Diagnostics.Should() - .OnlyContain(d => d.Message.Contains("Can not be parsed as a valid settings file")); + Collector.Diagnostics.Should().OnlyContain(d => d.Message.Contains("Can not be parsed as a valid settings file")); } } -public class NewSchemaRendersMetadataAndNestedSettings(ITestOutputHelper output) : DirectiveTest(output, -""" +public class NewSchemaRendersMetadataAndNestedSettings(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{settings} _settings/new-schema.yml ::: """ @@ -75,7 +74,9 @@ public class NewSchemaRendersMetadataAndNestedSettings(ITestOutputHelper output) protected override void AddToFileSystem(MockFileSystem fileSystem) { // language=yaml - fileSystem.AddFile("docs/_settings/new-schema.yml", """ + fileSystem.AddFile( + "docs/_settings/new-schema.yml", + """ product: Kibana collection: Test collection groups: @@ -94,7 +95,8 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) - setting: "[n].url" description: Child setting description. datatype: string -"""); +""" + ); } [Fact] @@ -108,10 +110,7 @@ public void RendersAppliesToAndMetadata() } [Fact] - public void RendersNestedSettingName() - { - Html.Should().Contain("xpack.actions.customHostSettings[n].url"); - } + public void RendersNestedSettingName() => Html.Should().Contain("xpack.actions.customHostSettings[n].url"); [Fact] public void DotsInSettingNamesAreHyphensInAnchors() @@ -121,14 +120,12 @@ public void DotsInSettingNamesAreHyphensInAnchors() } [Fact] - public void NestedSettingAnchorIncludesParentPrefix() - { - Html.Should().Contain("id=\"xpack-actions-customhostsettingsn-url\""); - } + public void NestedSettingAnchorIncludesParentPrefix() => Html.Should().Contain("id=\"xpack-actions-customhostsettingsn-url\""); } -public class LegacySourceBlocksRenderAsMarkdownCode(ITestOutputHelper output) : DirectiveTest(output, -""" +public class LegacySourceBlocksRenderAsMarkdownCode(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{settings} _settings/legacy-source.yml ::: """ @@ -137,7 +134,9 @@ public class LegacySourceBlocksRenderAsMarkdownCode(ITestOutputHelper output) : protected override void AddToFileSystem(MockFileSystem fileSystem) { // language=yaml - fileSystem.AddFile("docs/_settings/legacy-source.yml", """ + fileSystem.AddFile( + "docs/_settings/legacy-source.yml", + """ groups: - group: Example settings: @@ -150,7 +149,8 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) xpack.legacy.example: enabled: true -- -"""); +""" + ); } [Fact] @@ -161,8 +161,9 @@ public void RendersAsFencedCodeBlock() } } -public class SettingsTopMatterAndTitlesRender(ITestOutputHelper output) : DirectiveTest(output, -""" +public class SettingsTopMatterAndTitlesRender(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{settings} _settings/top-matter.yml :::: """ @@ -173,7 +174,9 @@ public class SettingsTopMatterAndTitlesRender(ITestOutputHelper output) : Direct protected override void AddToFileSystem(MockFileSystem fileSystem) { // language=yaml - fileSystem.AddFile("docs/_settings/top-matter.yml", """ + fileSystem.AddFile( + "docs/_settings/top-matter.yml", + """ product: Kibana collection: Test collection page_description: | @@ -185,7 +188,8 @@ Read the [preconfigured connectors](/reference/connectors-kibana/pre-configured- settings: - setting: xpack.sample.enabled description: "Enables sample behavior." -"""); +""" + ); } [Fact] @@ -198,8 +202,9 @@ public void RendersPageDescriptionNotesAndInterpolatedGroupTitle() } } -public class SettingsApplicabilityRowsPreferUsefulBadges(ITestOutputHelper output) : DirectiveTest(output, -""" +public class SettingsApplicabilityRowsPreferUsefulBadges(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{settings} _settings/applicability-rows.yml :::: """ @@ -208,7 +213,9 @@ public class SettingsApplicabilityRowsPreferUsefulBadges(ITestOutputHelper outpu protected override void AddToFileSystem(MockFileSystem fileSystem) { // language=yaml - fileSystem.AddFile("docs/_settings/applicability-rows.yml", """ + fileSystem.AddFile( + "docs/_settings/applicability-rows.yml", + """ groups: - group: Example settings: @@ -218,7 +225,8 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) stack: ga self: ga ech: unavailable -"""); +""" + ); } [Fact] @@ -259,8 +267,9 @@ public void DoesNotRenderGenericStackBadgeOrUnavailableSupportedOnEntry() /// - console.ui.enabled → ech: unavailable, self: ga → ECH hidden /// Settings with no applies_to at all (universally available) are also visible. /// -public class DeploymentFilterEchOnKibanaGeneralSettings(ITestOutputHelper output) : DirectiveTest(output, -$$""" +public class DeploymentFilterEchOnKibanaGeneralSettings(ITestOutputHelper output) : DirectiveTest( + output, + $$""" :::{settings} /{{GeneralSettingsPath.Replace("docs/", "")}} :deployment: ech ::: @@ -277,12 +286,10 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) } [Fact] - public void ShowsEchGaSetting() => - Html.Should().Contain("execution_context.enabled"); + public void ShowsEchGaSetting() => Html.Should().Contain("execution_context.enabled"); [Fact] - public void HidesEchUnavailableSetting() => - Html.Should().NotContain("console.ui.enabled"); + public void HidesEchUnavailableSetting() => Html.Should().NotContain("console.ui.enabled"); } /// @@ -290,8 +297,9 @@ public void HidesEchUnavailableSetting() => /// it must be treated as unavailable for ECH — "missing means unavailable". /// Uses the real kibana-general-settings.yml which has self-only and ech:unavailable patterns. /// -public class DeploymentFilterEchMissingMeansUnavailable(ITestOutputHelper output) : DirectiveTest(output, -""" +public class DeploymentFilterEchMissingMeansUnavailable(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{settings} _settings/self-only.yml :deployment: ech ::: @@ -301,7 +309,9 @@ public class DeploymentFilterEchMissingMeansUnavailable(ITestOutputHelper output protected override void AddToFileSystem(MockFileSystem fileSystem) { // language=yaml - fileSystem.AddFile("docs/_settings/self-only.yml", """ + fileSystem.AddFile( + "docs/_settings/self-only.yml", + """ groups: - group: Example settings: @@ -316,24 +326,23 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) self: ga - setting: no.applies.to.setting description: No applies_to at all — universally available. -"""); +""" + ); } [Fact] - public void HidesSettingWhenEchIsMissing() => - Html.Should().NotContain("self.only.setting"); + public void HidesSettingWhenEchIsMissing() => Html.Should().NotContain("self.only.setting"); [Fact] - public void ShowsSettingWithExplicitEchGa() => - Html.Should().Contain("ech.explicit.setting"); + public void ShowsSettingWithExplicitEchGa() => Html.Should().Contain("ech.explicit.setting"); [Fact] - public void ShowsSettingWithNoAppliesTo() => - Html.Should().Contain("no.applies.to.setting"); + public void ShowsSettingWithNoAppliesTo() => Html.Should().Contain("no.applies.to.setting"); } -public class DeploymentFilterWithUnknownValueEmitsWarning(ITestOutputHelper output) : DirectiveTest(output, -$$""" +public class DeploymentFilterWithUnknownValueEmitsWarning(ITestOutputHelper output) : DirectiveTest( + output, + $$""" :::{settings} /{{GeneralSettingsPath.Replace("docs/", "")}} :deployment: invalid-deployment ::: @@ -351,16 +360,15 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) [Fact] public void EmitsWarning() => - Collector.Diagnostics.Should() - .Contain(d => d.Severity == Severity.Warning && d.Message.Contains("invalid-deployment")); + Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Warning && d.Message.Contains("invalid-deployment")); [Fact] - public void StillRendersAllSettingsWhenFilterIsInvalid() => - Html.Should().Contain("execution_context.enabled"); + public void StillRendersAllSettingsWhenFilterIsInvalid() => Html.Should().Contain("execution_context.enabled"); } -public class AppliesToInlineRoleInDescriptionRendersAsBadge(ITestOutputHelper output) : DirectiveTest(output, -""" +public class AppliesToInlineRoleInDescriptionRendersAsBadge(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{settings} _settings/applies-to-in-description.yml :::: """ @@ -369,7 +377,9 @@ public class AppliesToInlineRoleInDescriptionRendersAsBadge(ITestOutputHelper ou protected override void AddToFileSystem(MockFileSystem fileSystem) { // language=yaml - fileSystem.AddFile("docs/_settings/applies-to-in-description.yml", """ + fileSystem.AddFile( + "docs/_settings/applies-to-in-description.yml", + """ groups: - group: Example settings: @@ -379,7 +389,8 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) * {applies_to}`stack: ga 9.2` Defaults to `model-a`. * {applies_to}`stack: ga 9.1` Defaults to `model-b`. -"""); +""" + ); } [Fact] @@ -398,8 +409,9 @@ public void RendersAppliesToRoleAsBadgeNotLiteralText() /// The test stack current is 8.0.0 (see ), /// so stack: ga 9.5 is unreleased. /// -public class HidesSupportedOnLineWhenStackIsFullyPlanned(ITestOutputHelper output) : DirectiveTest(output, -""" +public class HidesSupportedOnLineWhenStackIsFullyPlanned(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{settings} _settings/stack-fully-planned.yml :::: """ @@ -408,7 +420,9 @@ public class HidesSupportedOnLineWhenStackIsFullyPlanned(ITestOutputHelper outpu protected override void AddToFileSystem(MockFileSystem fileSystem) { // language=yaml - fileSystem.AddFile("docs/_settings/stack-fully-planned.yml", """ + fileSystem.AddFile( + "docs/_settings/stack-fully-planned.yml", + """ groups: - group: Example settings: @@ -418,12 +432,12 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) stack: ga 99.99 ech: ga self: ga -"""); +""" + ); } [Fact] - public void RendersPlannedStackBadge() => - Html.Should().Contain("badge-key=\"Stack\"").And.Contain("Planned"); + public void RendersPlannedStackBadge() => Html.Should().Contain("badge-key=\"Stack\"").And.Contain("Planned"); [Fact] public void DoesNotRenderSupportedOnLine() @@ -444,8 +458,9 @@ public void DoesNotRenderEchOrSelfManagedBadges() /// A setting whose stack is released today (stack: ga 7.0 with test current 8.0.0) /// must continue to render the "Supported on" line with ECH and Self-managed badges. /// -public class KeepsSupportedOnLineWhenStackIsReleased(ITestOutputHelper output) : DirectiveTest(output, -""" +public class KeepsSupportedOnLineWhenStackIsReleased(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{settings} _settings/stack-released.yml :::: """ @@ -454,7 +469,9 @@ public class KeepsSupportedOnLineWhenStackIsReleased(ITestOutputHelper output) : protected override void AddToFileSystem(MockFileSystem fileSystem) { // language=yaml - fileSystem.AddFile("docs/_settings/stack-released.yml", """ + fileSystem.AddFile( + "docs/_settings/stack-released.yml", + """ groups: - group: Example settings: @@ -464,7 +481,8 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) stack: ga 7.0 ech: ga self: ga -"""); +""" + ); } [Fact] @@ -481,8 +499,9 @@ public void RendersSupportedOnLineWithBothBadges() /// (e.g. stack: ga 7.0, deprecated 9.0) is still usable today, /// so the "Supported on" line must remain visible. /// -public class KeepsSupportedOnLineWhenStackHasMixedReleaseAndFutureVersions(ITestOutputHelper output) : DirectiveTest(output, -""" +public class KeepsSupportedOnLineWhenStackHasMixedReleaseAndFutureVersions(ITestOutputHelper output) : DirectiveTest( + output, + """ :::{settings} _settings/stack-mixed-versions.yml :::: """ @@ -491,7 +510,9 @@ public class KeepsSupportedOnLineWhenStackHasMixedReleaseAndFutureVersions(ITest protected override void AddToFileSystem(MockFileSystem fileSystem) { // language=yaml - fileSystem.AddFile("docs/_settings/stack-mixed-versions.yml", """ + fileSystem.AddFile( + "docs/_settings/stack-mixed-versions.yml", + """ groups: - group: Example settings: @@ -501,7 +522,8 @@ protected override void AddToFileSystem(MockFileSystem fileSystem) stack: ga 7.0, deprecated 99.0 ech: ga self: ga -"""); +""" + ); } [Fact] diff --git a/tests/Elastic.Markdown.Tests/TaskList/BasicTaskListTests.cs b/tests/Elastic.Markdown.Tests/TaskList/BasicTaskListTests.cs index 5d9ee124f8..6bbe054671 100644 --- a/tests/Elastic.Markdown.Tests/TaskList/BasicTaskListTests.cs +++ b/tests/Elastic.Markdown.Tests/TaskList/BasicTaskListTests.cs @@ -7,25 +7,20 @@ namespace Elastic.Markdown.Tests.TaskList; -public class BasicTaskListTests(ITestOutputHelper output) - : InlineTest(output, """ +public class BasicTaskListTests(ITestOutputHelper output) : InlineTest(output, """ - [ ] A pending task - [x] A completed task """) { [Fact] - public void RendersTaskListContainer() => - Html.Should().Contain("class=\"contains-task-list\""); + public void RendersTaskListContainer() => Html.Should().Contain("class=\"contains-task-list\""); [Fact] - public void RendersTaskListItem() => - Html.Should().Contain("class=\"task-list-item\""); + public void RendersTaskListItem() => Html.Should().Contain("class=\"task-list-item\""); [Fact] - public void RendersUncheckedCheckbox() => - Html.ShouldContainHtml(""""""); + public void RendersUncheckedCheckbox() => Html.ShouldContainHtml(""""""); [Fact] - public void RendersCheckedCheckbox() => - Html.ShouldContainHtml(""""""); + public void RendersCheckedCheckbox() => Html.ShouldContainHtml(""""""); } diff --git a/tests/Elastic.Markdown.Tests/TestCodexCrossLinkResolver.cs b/tests/Elastic.Markdown.Tests/TestCodexCrossLinkResolver.cs index 5de998ff42..53b7c6d8e2 100644 --- a/tests/Elastic.Markdown.Tests/TestCodexCrossLinkResolver.cs +++ b/tests/Elastic.Markdown.Tests/TestCodexCrossLinkResolver.cs @@ -20,7 +20,8 @@ public class TestCodexCrossLinkResolver : ICrossLinkResolver public TestCodexCrossLinkResolver(bool useRelativePaths) { // language=json - var json = """ + var json = + """ { "content_source": "current", "origin": { @@ -53,14 +54,18 @@ public TestCodexCrossLinkResolver(bool useRelativePaths) var codexRepositories = new HashSet { "docs-content", "kibana" }.ToFrozenSet(); - var indexEntries = linkReferences.ToDictionary(e => e.Key, e => new LinkRegistryEntry - { - Repository = e.Key, - Path = $"elastic/docs-builder-tests/{e.Key}/links.json", - Branch = "main", - ETag = Guid.NewGuid().ToString(), - GitReference = Guid.NewGuid().ToString() - }); + var indexEntries = linkReferences.ToDictionary( + e => e.Key, + e => + new LinkRegistryEntry + { + Repository = e.Key, + Path = $"elastic/docs-builder-tests/{e.Key}/links.json", + Branch = "main", + ETag = Guid.NewGuid().ToString(), + GitReference = Guid.NewGuid().ToString() + } + ); _crossLinks = new FetchedCrossLinks { DeclaredRepositories = declaredRepositories, diff --git a/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs b/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs index 9ac8a574f6..a05487c16e 100644 --- a/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs +++ b/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs @@ -19,7 +19,8 @@ public class TestCrossLinkResolver : ICrossLinkResolver public TestCrossLinkResolver() { // language=json - var json = """ + var json = + """ { "content_source": "current", "origin": { @@ -50,14 +51,18 @@ public TestCrossLinkResolver() linkReferences.Add("kibana", reference); declaredRepositories.AddRange(["docs-content", "kibana"]); - var indexEntries = linkReferences.ToDictionary(e => e.Key, e => new LinkRegistryEntry - { - Repository = e.Key, - Path = $"elastic/docs-builder-tests/{e.Key}/links.json", - Branch = "main", - ETag = Guid.NewGuid().ToString(), - GitReference = Guid.NewGuid().ToString() - }); + var indexEntries = linkReferences.ToDictionary( + e => e.Key, + e => + new LinkRegistryEntry + { + Repository = e.Key, + Path = $"elastic/docs-builder-tests/{e.Key}/links.json", + Branch = "main", + ETag = Guid.NewGuid().ToString(), + GitReference = Guid.NewGuid().ToString() + } + ); _crossLinks = new FetchedCrossLinks { DeclaredRepositories = declaredRepositories, diff --git a/tests/Elastic.Markdown.Tests/TestDiagnosticsCollector.cs b/tests/Elastic.Markdown.Tests/TestDiagnosticsCollector.cs index e7a65736d9..fc9eefe21b 100644 --- a/tests/Elastic.Markdown.Tests/TestDiagnosticsCollector.cs +++ b/tests/Elastic.Markdown.Tests/TestDiagnosticsCollector.cs @@ -17,8 +17,7 @@ public void Write(Diagnostic diagnostic) } } -public class TestDiagnosticsCollector(ITestOutputHelper output) - : DiagnosticsCollector([new TestDiagnosticsOutput(output)]) +public class TestDiagnosticsCollector(ITestOutputHelper output) : DiagnosticsCollector([new TestDiagnosticsOutput(output)]) { private readonly List _diagnostics = []; diff --git a/tests/Elastic.Markdown.Tests/TestHelpers.cs b/tests/Elastic.Markdown.Tests/TestHelpers.cs index 07296fc9c4..5abc973a3b 100644 --- a/tests/Elastic.Markdown.Tests/TestHelpers.cs +++ b/tests/Elastic.Markdown.Tests/TestHelpers.cs @@ -28,7 +28,8 @@ public static class TestHelpers public static DocumentationFileSystem CreateDocumentationFileSystem( MockFileSystem fileSystem, IDirectoryInfo? invocation = null, - GitCheckoutInformation? git = null) + GitCheckoutInformation? git = null + ) { var gitPath = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".git"); if (!fileSystem.Directory.Exists(gitPath)) @@ -37,14 +38,19 @@ public static DocumentationFileSystem CreateDocumentationFileSystem( return DocumentationFileSystem.Resolve(invocation, new DocumentationScopeOptions { Inner = fileSystem, Git = git }); } - public static IConfigurationContext CreateConfigurationContext(IFileSystem fileSystem, VersionsConfiguration? versionsConfiguration = null, ProductsConfiguration? productsConfiguration = null) + public static IConfigurationContext CreateConfigurationContext( + IFileSystem fileSystem, + VersionsConfiguration? versionsConfiguration = null, + ProductsConfiguration? productsConfiguration = null + ) { versionsConfiguration ??= new VersionsConfiguration { VersioningSystems = new Dictionary { { - VersioningSystemId.Stack, new VersioningSystem + VersioningSystemId.Stack, + new VersioningSystem { Id = VersioningSystemId.Stack, Current = new SemVersion(8, 0, 0), @@ -52,36 +58,20 @@ public static IConfigurationContext CreateConfigurationContext(IFileSystem fileS } }, { - VersioningSystemId.Self, new VersioningSystem - { - Id = VersioningSystemId.Self, - Current = new SemVersion(8, 0, 0), - Base = new SemVersion(8, 0, 0) - } + VersioningSystemId.Self, + new VersioningSystem { Id = VersioningSystemId.Self, Current = new SemVersion(8, 0, 0), Base = new SemVersion(8, 0, 0) } }, { - VersioningSystemId.Ess, new VersioningSystem - { - Id = VersioningSystemId.Ess, - Current = new SemVersion(8, 0, 0), - Base = new SemVersion(8, 0, 0) - } + VersioningSystemId.Ess, + new VersioningSystem { Id = VersioningSystemId.Ess, Current = new SemVersion(8, 0, 0), Base = new SemVersion(8, 0, 0) } }, { - VersioningSystemId.Eck, new VersioningSystem - { - Id = VersioningSystemId.Eck, - Current = new SemVersion(8, 0, 0), - Base = new SemVersion(8, 0, 0) - } + VersioningSystemId.Eck, + new VersioningSystem { Id = VersioningSystemId.Eck, Current = new SemVersion(8, 0, 0), Base = new SemVersion(8, 0, 0) } }, { - VersioningSystemId.Ece, new VersioningSystem - { - Id = VersioningSystemId.Ece, - Current = new SemVersion(8, 0, 0), - Base = new SemVersion(8, 0, 0) - } + VersioningSystemId.Ece, + new VersioningSystem { Id = VersioningSystemId.Ece, Current = new SemVersion(8, 0, 0), Base = new SemVersion(8, 0, 0) } } }, }; @@ -90,7 +80,8 @@ public static IConfigurationContext CreateConfigurationContext(IFileSystem fileS var products = new Dictionary { { - "elasticsearch", new Product + "elasticsearch", + new Product { Id = "elasticsearch", DisplayName = "Elasticsearch", @@ -98,7 +89,8 @@ public static IConfigurationContext CreateConfigurationContext(IFileSystem fileS } }, { - "kibana", new Product + "kibana", + new Product { Id = "kibana", DisplayName = "Kibana", @@ -106,7 +98,8 @@ public static IConfigurationContext CreateConfigurationContext(IFileSystem fileS } }, { - "apm", new Product + "apm", + new Product { Id = "apm", DisplayName = "APM", @@ -114,7 +107,8 @@ public static IConfigurationContext CreateConfigurationContext(IFileSystem fileS } }, { - "apm-agent", new Product + "apm-agent", + new Product { Id = "apm-agent", DisplayName = "APM Agent", @@ -132,11 +126,12 @@ public static IConfigurationContext CreateConfigurationContext(IFileSystem fileS var search = new SearchConfiguration { Synonyms = [], Rules = [], DiminishTerms = [] }; return new ConfigurationContext { - Endpoints = new DocumentationEndpoints - { - Elasticsearch = ElasticsearchEndpoint.Default, - }, - ConfigurationFileProvider = new ConfigurationFileProvider(new TestLoggerFactory(TestContext.Current.TestOutputHelper), new ConfigurationFileSystem(fileSystem)), + Endpoints = new DocumentationEndpoints { Elasticsearch = ElasticsearchEndpoint.Default, }, + ConfigurationFileProvider = + new ConfigurationFileProvider( + new TestLoggerFactory(TestContext.Current.TestOutputHelper), + new ConfigurationFileSystem(fileSystem) + ), VersionsConfiguration = versionsConfiguration, ProductsConfiguration = productsConfiguration, SearchConfiguration = search, diff --git a/tests/Elastic.Markdown.Tests/TestLogger.cs b/tests/Elastic.Markdown.Tests/TestLogger.cs index 3154bb8d40..68da7c3e7e 100644 --- a/tests/Elastic.Markdown.Tests/TestLogger.cs +++ b/tests/Elastic.Markdown.Tests/TestLogger.cs @@ -17,8 +17,13 @@ public void Dispose() { } public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Trace; - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => - output?.WriteLine(formatter(state, exception)); + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) => output?.WriteLine(formatter(state, exception)); } public class TestLoggerProvider(ITestOutputHelper? output) : ILoggerProvider diff --git a/tests/Elastic.SiteSearch.Tests/ContentStackMappingTests.cs b/tests/Elastic.SiteSearch.Tests/ContentStackMappingTests.cs index 731d0b9031..6c07763e1b 100644 --- a/tests/Elastic.SiteSearch.Tests/ContentStackMappingTests.cs +++ b/tests/Elastic.SiteSearch.Tests/ContentStackMappingTests.cs @@ -19,12 +19,7 @@ private static SyncItem LoadFixture(string fixtureName, string contentTypeUid) var path = Path.Combine("Fixtures", "ContentStack", fixtureName); var json = File.ReadAllText(path); var data = JsonSerializer.Deserialize(json); - return new SyncItem - { - Type = "entry_published", - ContentTypeUid = contentTypeUid, - Data = data - }; + return new SyncItem { Type = "entry_published", ContentTypeUid = contentTypeUid, Data = data }; } /// Covers: blog (legacy v1 with flat body_l10n) @@ -335,12 +330,7 @@ public void GetLanguageFromUrl_Detects_Locale_Prefixes() } private static SyncItem LoadFromJson(string json, string contentTypeUid) => - new() - { - Type = "entry_published", - ContentTypeUid = contentTypeUid, - Data = JsonSerializer.Deserialize(json) - }; + new() { Type = "entry_published", ContentTypeUid = contentTypeUid, Data = JsonSerializer.Deserialize(json) }; /// /// Root cause: ContentStack "publishes" the same entry into multiple locale variants that @@ -356,10 +346,13 @@ private static SyncItem LoadFromJson(string json, string contentTypeUid) => [Fact] public void ToSiteDocument_UnprefixedUrl_UnmappedNonEnglishLocale_NamespacesUnderBaseLanguageSubtag() { - var item = LoadFromJson(/*lang=json,strict*/ """ + var item = LoadFromJson(/*lang=json,strict*/ + """ { "title": "Support Matrix", "url": "/support/matrix", "locale": "xx-yy", "paragraph_l10n": "Supported versions." } - """, "support_matrix"); + """, + "support_matrix" + ); var doc = ContentStackMapper.ToSiteDocument(item); doc.Should().NotBeNull(); @@ -371,10 +364,13 @@ public void ToSiteDocument_UnprefixedUrl_UnmappedNonEnglishLocale_NamespacesUnde [Fact] public void ToSiteDocument_MissingLocale_ResolvesToEnglish() { - var item = LoadFromJson(/*lang=json,strict*/ """ + var item = LoadFromJson(/*lang=json,strict*/ + """ { "title": "Support Matrix", "url": "/support/matrix", "paragraph_l10n": "Supported versions." } - """, "support_matrix"); + """, + "support_matrix" + ); var doc = ContentStackMapper.ToSiteDocument(item); doc.Should().NotBeNull(); @@ -386,10 +382,13 @@ public void ToSiteDocument_MissingLocale_ResolvesToEnglish() [Fact] public void ToSiteDocument_EnglishVariantLocale_ResolvesToEnglish() { - var item = LoadFromJson(/*lang=json,strict*/ """ + var item = LoadFromJson(/*lang=json,strict*/ + """ { "title": "Support Matrix", "url": "/support/matrix", "locale": "en-gb", "paragraph_l10n": "Supported versions." } - """, "support_matrix"); + """, + "support_matrix" + ); var doc = ContentStackMapper.ToSiteDocument(item); doc.Should().NotBeNull(); @@ -403,10 +402,13 @@ public void ToSiteDocument_EnglishVariantLocale_ResolvesToEnglish() [Fact] public void ToSiteDocument_PrefixedUrl_ResolvesPerPrefix_RegardlessOfLocale() { - var item = LoadFromJson(/*lang=json,strict*/ """ + var item = LoadFromJson(/*lang=json,strict*/ + """ { "title": "Support Matrix", "url": "/de/support/matrix", "locale": "en-us", "paragraph_l10n": "Supported versions." } - """, "support_matrix"); + """, + "support_matrix" + ); var doc = ContentStackMapper.ToSiteDocument(item); doc.Should().NotBeNull(); @@ -423,10 +425,13 @@ public void ToSiteDocument_PrefixedUrl_ResolvesPerPrefix_RegardlessOfLocale() [Fact] public void ToSiteDocument_NonMasterLocale_NamespacesUrlUnderSitePrefix() { - var item = LoadFromJson(/*lang=json,strict*/ """ + var item = LoadFromJson(/*lang=json,strict*/ + """ { "title": "Support Matrix", "url": "/support/matrix", "locale": "es-mx", "paragraph_l10n": "Supported versions." } - """, "support_matrix"); + """, + "support_matrix" + ); var doc = ContentStackMapper.ToSiteDocument(item); doc.Should().NotBeNull(); diff --git a/tests/Elastic.SiteSearch.Tests/DocumentSerializationTests.cs b/tests/Elastic.SiteSearch.Tests/DocumentSerializationTests.cs index a1a5044991..ba8c86620a 100644 --- a/tests/Elastic.SiteSearch.Tests/DocumentSerializationTests.cs +++ b/tests/Elastic.SiteSearch.Tests/DocumentSerializationTests.cs @@ -12,10 +12,7 @@ namespace Elastic.SiteSearch.Tests; public class DocumentSerializationTests { - private static readonly JsonSerializerOptions Options = new() - { - TypeInfoResolver = SourceGenerationContext.Default - }; + private static readonly JsonSerializerOptions Options = new() { TypeInfoResolver = SourceGenerationContext.Default }; /// /// Options that configure SearchDocumentBase as a fallback-safe polymorphic root. @@ -23,15 +20,14 @@ public class DocumentSerializationTests /// private static readonly JsonSerializerOptions FallbackOptions = new() { - TypeInfoResolver = JsonTypeInfoResolver.Combine(SourceGenerationContext.Default) - .WithAddedModifier(SearchDocumentPolymorphism.WithFallback()) + TypeInfoResolver = + JsonTypeInfoResolver.Combine(SourceGenerationContext.Default).WithAddedModifier(SearchDocumentPolymorphism.WithFallback()) }; /// /// is JSON-driven; AutoBogus would assign unrelated strings if populated. /// - private static AutoFaker CreateAutoFaker() - where T : SearchDocumentBase => + private static AutoFaker CreateAutoFaker() where T : SearchDocumentBase => new AutoFaker().Configure(builder => { #pragma warning disable CA2263 // Skip path must match the concrete faker type T (inherited ContentType). @@ -119,7 +115,8 @@ public void MissingDiscriminator_ReadAs_ISearchDocument_Throws() // because the interface cannot be instantiated as a fallback. // Callers that don't have a discriminator should read as SearchDocumentBase with // WithFallback() — see MissingDiscriminator_ReadAs_SearchDocumentBase_ReturnsFallback. - var json = """ + var json = + """ { "title": "Getting Started", "search_title": "Getting Started with Elasticsearch", @@ -137,7 +134,8 @@ public void UnknownDiscriminator_ReadAs_ISearchDocument_Throws() { // An unknown $type on an interface root still throws because the interface cannot be // instantiated as a fallback, even with IgnoreUnrecognizedTypeDiscriminators=true. - var json = """ + var json = + """ { "$type": "unknown-future-type", "title": "Some Page", @@ -156,7 +154,8 @@ public void MissingDiscriminator_ReadAs_SearchDocumentBase_ReturnsFallback() { // With WithFallback() applied, SearchDocumentBase is a concrete polymorphic root. // A missing $type materializes a SearchDocumentBase instance rather than throwing. - var json = """ + var json = + """ { "title": "Getting Started", "search_title": "Getting Started with Elasticsearch", @@ -178,7 +177,8 @@ public void UnknownDiscriminator_ReadAs_SearchDocumentBase_ReturnsFallback() { // With WithFallback() applied, an unrecognized $type yields a SearchDocumentBase // fallback instance instead of throwing. - var json = """ + var json = + """ { "$type": "unknown-future-type", "title": "Some Page", @@ -199,7 +199,8 @@ public void UnknownDiscriminator_ReadAs_SearchDocumentBase_ReturnsFallback() public void KnownDiscriminator_ReadAs_SearchDocumentBase_WithFallback_DispatchesToConcreteType() { // Even with WithFallback(), a known $type still dispatches to the correct concrete type. - var json = """ + var json = + """ { "$type": "site", "title": "Blog Post", @@ -218,7 +219,8 @@ public void KnownDiscriminator_ReadAs_SearchDocumentBase_WithFallback_Dispatches [Fact] public void ContentType_FromJson_Overrides_WhenPresent() { - var json = """ + var json = + """ { "title": "Legacy", "search_title": "Legacy", @@ -253,7 +255,8 @@ public void GuideDocument_Type_IsHardcoded() [Fact] public void NavigationFields_Roundtrip() { - var json = """ + var json = + """ { "$type": "site", "title": "Test", @@ -284,13 +287,7 @@ public void NavigationFields_Roundtrip() [Fact] public void NavigationFields_DefaultPenaltyValues() { - var doc = new SiteDocument - { - Title = "Test", - SearchTitle = "Test", - Path = "/x", - Hash = "h" - }; + var doc = new SiteDocument { Title = "Test", SearchTitle = "Test", Path = "/x", Hash = "h" }; // rank_feature defaults to 50 so documents without explicit nav metadata are penalised doc.Navigation.Depth.Should().Be(50); diff --git a/tests/Elastic.SiteSearch.Tests/IndexTimeSynonymsTests.cs b/tests/Elastic.SiteSearch.Tests/IndexTimeSynonymsTests.cs index 0ace6ab9aa..1de620fdac 100644 --- a/tests/Elastic.SiteSearch.Tests/IndexTimeSynonymsTests.cs +++ b/tests/Elastic.SiteSearch.Tests/IndexTimeSynonymsTests.cs @@ -14,14 +14,11 @@ namespace Elastic.SiteSearch.Tests; public class IndexTimeSynonymsTests { [Fact] - public void Docs_ContainsAggAliasRule() => - IndexTimeSynonyms.Docs.Should().Contain("agg, aggs => aggregations"); + public void Docs_ContainsAggAliasRule() => IndexTimeSynonyms.Docs.Should().Contain("agg, aggs => aggregations"); [Fact] - public void Docs_ContainsEsqlAliasRule() => - IndexTimeSynonyms.Docs.Should().Contain("esql, es|ql => esql"); + public void Docs_ContainsEsqlAliasRule() => IndexTimeSynonyms.Docs.Should().Contain("esql, es|ql => esql"); [Fact] - public void Docs_ContainsDataStreamsAliasRules() => - IndexTimeSynonyms.Docs.Should().Contain("data-streams, data streams, datastreams"); + public void Docs_ContainsDataStreamsAliasRules() => IndexTimeSynonyms.Docs.Should().Contain("data-streams, data streams, datastreams"); } diff --git a/tests/Elastic.SiteSearch.Tests/IndicesCleanupPlannerTests.cs b/tests/Elastic.SiteSearch.Tests/IndicesCleanupPlannerTests.cs index e4c0ac43d0..c581105ea2 100644 --- a/tests/Elastic.SiteSearch.Tests/IndicesCleanupPlannerTests.cs +++ b/tests/Elastic.SiteSearch.Tests/IndicesCleanupPlannerTests.cs @@ -21,7 +21,8 @@ private static Dictionary> Idx(params (string Name, entries.ToDictionary( e => e.Name, e => (IReadOnlySet)e.Aliases.ToHashSet(StringComparer.OrdinalIgnoreCase), - StringComparer.OrdinalIgnoreCase); + StringComparer.OrdinalIgnoreCase + ); [Fact] public void Empty_input_returns_empty_plan() @@ -36,8 +37,7 @@ public void Empty_input_returns_empty_plan() [Fact] public void Single_active_index_is_always_kept() { - var indexAliases = Idx( - ("test-source.lexical-prod-2026.01.01.000000", ["test-source.lexical-prod-latest"])); + var indexAliases = Idx(("test-source.lexical-prod-2026.01.01.000000", ["test-source.lexical-prod-latest"])); var plan = IndicesCleanupPlanner.Plan(indexAliases, [TestEntry], keep: 2); @@ -52,7 +52,8 @@ public void Active_is_newest_keep2_deletes_older_two() ("test-source.lexical-prod-2026.04.15.000000", ["test-source.lexical-prod-latest"]), ("test-source.lexical-prod-2026.04.14.000000", []), ("test-source.lexical-prod-2026.04.13.000000", []), - ("test-source.lexical-prod-2026.04.12.000000", [])); + ("test-source.lexical-prod-2026.04.12.000000", []) + ); var plan = IndicesCleanupPlanner.Plan(indexAliases, [TestEntry], keep: 2); @@ -73,7 +74,8 @@ public void Active_is_middle_keep2_is_still_retained() ("test-source.lexical-prod-2026.04.15.000000", []), ("test-source.lexical-prod-2026.04.14.000000", []), ("test-source.lexical-prod-2026.04.13.000000", ["test-source.lexical-prod-latest"]), - ("test-source.lexical-prod-2026.04.12.000000", [])); + ("test-source.lexical-prod-2026.04.12.000000", []) + ); var plan = IndicesCleanupPlanner.Plan(indexAliases, [TestEntry], keep: 2); @@ -93,7 +95,8 @@ public void Keep1_active_counts_no_non_active_kept() var indexAliases = Idx( ("test-source.lexical-prod-2026.04.15.000000", ["test-source.lexical-prod-latest"]), ("test-source.lexical-prod-2026.04.14.000000", []), - ("test-source.lexical-prod-2026.04.13.000000", [])); + ("test-source.lexical-prod-2026.04.13.000000", []) + ); var plan = IndicesCleanupPlanner.Plan(indexAliases, [TestEntry], keep: 1); @@ -105,9 +108,12 @@ public void Keep1_active_counts_no_non_active_kept() public void Indices_with_non_date_suffix_are_skipped_with_warning() { var indexAliases = Idx( - ("test-source.lexical-prod-latest", []), // alias itself, not a backing index - ("test-source.lexical-prod-not-a-date", []), // malformed - ("test-source.lexical-prod-2026.04.15.000000", ["test-source.lexical-prod-latest"])); + ("test-source.lexical-prod-latest", []), // alias itself, not a backing index + + ("test-source.lexical-prod-not-a-date", []), // malformed + + ("test-source.lexical-prod-2026.04.15.000000", ["test-source.lexical-prod-latest"]) + ); var plan = IndicesCleanupPlanner.Plan(indexAliases, [TestEntry], keep: 2); @@ -119,8 +125,20 @@ public void Indices_with_non_date_suffix_are_skipped_with_warning() [Fact] public void Multiple_groups_are_planned_independently() { - var lexicalEntry = new AliasEntry("test-source", "lexical", "prod", "test-source.lexical-prod-latest", "test-source.lexical-prod-*"); - var semanticEntry = new AliasEntry("test-source", "semantic", "prod", "test-source.semantic-prod-latest", "test-source.semantic-prod-*"); + var lexicalEntry = new AliasEntry( + "test-source", + "lexical", + "prod", + "test-source.lexical-prod-latest", + "test-source.lexical-prod-*" + ); + var semanticEntry = new AliasEntry( + "test-source", + "semantic", + "prod", + "test-source.semantic-prod-latest", + "test-source.semantic-prod-*" + ); var indexAliases = Idx( ("test-source.lexical-prod-2026.04.15.000000", ["test-source.lexical-prod-latest"]), @@ -128,7 +146,8 @@ public void Multiple_groups_are_planned_independently() ("test-source.lexical-prod-2026.04.13.000000", []), ("test-source.semantic-prod-2026.04.15.000000", ["test-source.semantic-prod-latest"]), ("test-source.semantic-prod-2026.04.14.000000", []), - ("test-source.semantic-prod-2026.04.13.000000", [])); + ("test-source.semantic-prod-2026.04.13.000000", []) + ); var plan = IndicesCleanupPlanner.Plan(indexAliases, [lexicalEntry, semanticEntry], keep: 2); @@ -145,7 +164,8 @@ public void Unrelated_indices_in_response_are_ignored() var indexAliases = Idx( ("test-source.lexical-prod-2026.04.15.000000", ["test-source.lexical-prod-latest"]), ("some-other-index-2026.04.15.000000", []), - ("completely-unrelated", ["some-alias"])); + ("completely-unrelated", ["some-alias"]) + ); var plan = IndicesCleanupPlanner.Plan(indexAliases, [TestEntry], keep: 2); @@ -163,14 +183,17 @@ public void Applying_the_plan_then_replanning_yields_no_further_deletions() ("test-source.lexical-prod-2026.04.15.000000", ["test-source.lexical-prod-latest"]), ("test-source.lexical-prod-2026.04.14.000000", []), ("test-source.lexical-prod-2026.04.13.000000", []), - ("test-source.lexical-prod-2026.04.12.000000", [])); + ("test-source.lexical-prod-2026.04.12.000000", []) + ); var firstPlan = IndicesCleanupPlanner.Plan(indexAliases, [TestEntry], keep: 2); firstPlan.ToDelete.Should().HaveCount(2); // sanity check against the scenario above - var afterApply = indexAliases - .Where(kv => firstPlan.ToDelete.All(d => d.Name != kv.Key)) - .ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase); + var afterApply = indexAliases.Where(kv => firstPlan.ToDelete.All(d => d.Name != kv.Key)).ToDictionary( + kv => kv.Key, + kv => kv.Value, + StringComparer.OrdinalIgnoreCase + ); var secondPlan = IndicesCleanupPlanner.Plan(afterApply, [TestEntry], keep: 2); @@ -199,17 +222,22 @@ public void BuildAliasEntries_returns_ten_entries() public void PageAlias_on_older_index_keeps_it_regardless_of_keep_budget() { // ws-content-prod points to an older index that -latest does not; it must not be deleted - var semanticEntry = new AliasEntry("ws-catalog", "semantic", "prod", - "ws-catalog.semantic-prod-latest", "ws-catalog.semantic-prod-*"); + var semanticEntry = new AliasEntry( + "ws-catalog", + "semantic", + "prod", + "ws-catalog.semantic-prod-latest", + "ws-catalog.semantic-prod-*" + ); var indexAliases = Idx( ("ws-catalog.semantic-prod-2026.04.15.000000", ["ws-catalog.semantic-prod-latest"]), ("ws-catalog.semantic-prod-2026.04.14.000000", ["ws-content-prod"]), - ("ws-catalog.semantic-prod-2026.04.13.000000", [])); + ("ws-catalog.semantic-prod-2026.04.13.000000", []) + ); // keep=1 would normally only retain the active index and delete the other two; // but the page-alias index must also survive - var plan = IndicesCleanupPlanner.Plan(indexAliases, [semanticEntry], keep: 1, - pageAlias: "ws-content-prod"); + var plan = IndicesCleanupPlanner.Plan(indexAliases, [semanticEntry], keep: 1, pageAlias: "ws-content-prod"); plan.ToKeep.Should().HaveCount(2); plan.ToKeep.Should().Contain(i => i.Name == "ws-catalog.semantic-prod-2026.04.15.000000" && i.IsActive); @@ -220,16 +248,21 @@ public void PageAlias_on_older_index_keeps_it_regardless_of_keep_budget() [Fact] public void PageAlias_pointing_to_different_index_than_semantic_latest_emits_warning() { - var semanticEntry = new AliasEntry("ws-catalog", "semantic", "prod", - "ws-catalog.semantic-prod-latest", "ws-catalog.semantic-prod-*"); + var semanticEntry = new AliasEntry( + "ws-catalog", + "semantic", + "prod", + "ws-catalog.semantic-prod-latest", + "ws-catalog.semantic-prod-*" + ); var indexAliases = Idx( // -latest points here ("ws-catalog.semantic-prod-2026.04.15.000000", ["ws-catalog.semantic-prod-latest"]), // pages alias points to an older index — mismatch! - ("ws-catalog.semantic-prod-2026.04.14.000000", ["ws-content-prod"])); + ("ws-catalog.semantic-prod-2026.04.14.000000", ["ws-content-prod"]) + ); - var plan = IndicesCleanupPlanner.Plan(indexAliases, [semanticEntry], keep: 2, - pageAlias: "ws-content-prod"); + var plan = IndicesCleanupPlanner.Plan(indexAliases, [semanticEntry], keep: 2, pageAlias: "ws-content-prod"); // Both indices must be kept (one via -latest, one via page alias) plan.ToDelete.Should().BeEmpty(); diff --git a/tests/Elastic.SiteSearch.Tests/LabsHtmlExtractorTests.cs b/tests/Elastic.SiteSearch.Tests/LabsHtmlExtractorTests.cs index a6a41d741a..44b8a598f7 100644 --- a/tests/Elastic.SiteSearch.Tests/LabsHtmlExtractorTests.cs +++ b/tests/Elastic.SiteSearch.Tests/LabsHtmlExtractorTests.cs @@ -17,7 +17,8 @@ public class LabsHtmlExtractorTests /// boilerplate that should be stripped: CTA banner, feedback widget, share /// buttons, and related-content cards. /// - private const string SynonymsHtml = """ + private const string SynonymsHtml = + """ @@ -85,8 +86,15 @@ public class LabsHtmlExtractorTests [Fact] public async Task Url_Uses_Path_When_Absolute_Uri() { - var doc = await _extractor.ExtractAsync("https://www.elastic.co/search-labs/blog/elasticsearch-synonyms-ui", SynonymsHtml, null, "en", "search-labs" -, TestContext.Current.CancellationToken); + var doc = + await _extractor.ExtractAsync( + "https://www.elastic.co/search-labs/blog/elasticsearch-synonyms-ui", + SynonymsHtml, + null, + "en", + "search-labs", + TestContext.Current.CancellationToken + ); doc.Should().NotBeNull(); doc.Path.Should().Be("/search-labs/blog/elasticsearch-synonyms-ui"); @@ -95,8 +103,15 @@ public async Task Url_Uses_Path_When_Absolute_Uri() [Fact] public async Task Url_Strips_Query_And_Fragment() { - var doc = await _extractor.ExtractAsync("https://www.elastic.co/search-labs/blog/post?utm=foo#section", SynonymsHtml, null, "en", "search-labs" -, TestContext.Current.CancellationToken); + var doc = + await _extractor.ExtractAsync( + "https://www.elastic.co/search-labs/blog/post?utm=foo#section", + SynonymsHtml, + null, + "en", + "search-labs", + TestContext.Current.CancellationToken + ); doc.Should().NotBeNull(); doc.Path.Should().Be("/search-labs/blog/post"); @@ -105,8 +120,15 @@ public async Task Url_Strips_Query_And_Fragment() [Fact] public async Task Extracts_Title_And_Headings() { - var doc = await _extractor.ExtractAsync("https://www.elastic.co/search-labs/blog/elasticsearch-synonyms-ui", SynonymsHtml, null, "en", "search-labs" -, TestContext.Current.CancellationToken); + var doc = + await _extractor.ExtractAsync( + "https://www.elastic.co/search-labs/blog/elasticsearch-synonyms-ui", + SynonymsHtml, + null, + "en", + "search-labs", + TestContext.Current.CancellationToken + ); doc.Should().NotBeNull(); doc.Title.Should().Be("How to use the Synonyms UI"); @@ -121,8 +143,15 @@ public async Task Returns_Null_For_Missing_Title()

    No title here

    """; - var doc = await _extractor.ExtractAsync("https://www.elastic.co/search-labs/blog/no-title", html, null, "en", "search-labs" -, TestContext.Current.CancellationToken); + var doc = + await _extractor.ExtractAsync( + "https://www.elastic.co/search-labs/blog/no-title", + html, + null, + "en", + "search-labs", + TestContext.Current.CancellationToken + ); doc.Should().BeNull(); } @@ -203,7 +232,8 @@ public void GetNavigationSection_Resolves_Correctly(string url, string expected) /// entirely from the article's own headline - e.g. "ES|QL Kibana: The ES|QL editor experience /// in Kibana" (og:title) vs. "Improving the ES|QL editor experience in Kibana" (h1). /// - private const string EsqlEditorHtml = """ + private const string EsqlEditorHtml = + """ @@ -224,8 +254,15 @@ public void GetNavigationSection_Resolves_Correctly(string url, string expected) [Fact] public async Task Title_Prefers_Article_Heading_Over_Seo_Title() { - var doc = await _extractor.ExtractAsync("https://www.elastic.co/search-labs/blog/improving-esql-editor-experience-in-kibana", EsqlEditorHtml, null, "en", "search-labs" -, TestContext.Current.CancellationToken); + var doc = + await _extractor.ExtractAsync( + "https://www.elastic.co/search-labs/blog/improving-esql-editor-experience-in-kibana", + EsqlEditorHtml, + null, + "en", + "search-labs", + TestContext.Current.CancellationToken + ); doc.Should().NotBeNull(); doc.Title.Should().Be("Improving the ES|QL editor experience in Kibana"); @@ -236,8 +273,15 @@ public async Task Title_Prefers_Article_Heading_Over_Seo_Title() [Fact] public async Task Abstract_Folds_In_Description_Without_Heading_Brackets() { - var doc = await _extractor.ExtractAsync("https://www.elastic.co/search-labs/blog/improving-esql-editor-experience-in-kibana", EsqlEditorHtml, null, "en", "search-labs" -, TestContext.Current.CancellationToken); + var doc = + await _extractor.ExtractAsync( + "https://www.elastic.co/search-labs/blog/improving-esql-editor-experience-in-kibana", + EsqlEditorHtml, + null, + "en", + "search-labs", + TestContext.Current.CancellationToken + ); doc.Should().NotBeNull(); doc.Summary.Should().StartWith("With the new ES|QL language becoming GA"); @@ -248,12 +292,21 @@ public async Task Abstract_Folds_In_Description_Without_Heading_Brackets() [Fact] public async Task Root_Overview_Title_Gets_No_Redundant_SearchTitle_Suffix() { - const string html = """ + const string html = + """ Search Labs | Elastic

    Search Labs

    Technical content from the team behind Elasticsearch.

    """; - var doc = await _extractor.ExtractAsync("https://www.elastic.co/search-labs", html, null, "en", "search-labs", TestContext.Current.CancellationToken); + var doc = + await _extractor.ExtractAsync( + "https://www.elastic.co/search-labs", + html, + null, + "en", + "search-labs", + TestContext.Current.CancellationToken + ); doc.Should().NotBeNull(); doc.Title.Should().Be("Search Labs"); @@ -261,7 +314,8 @@ public async Task Root_Overview_Title_Gets_No_Redundant_SearchTitle_Suffix() doc.Navigation.TableOfContents.Should().Be(10); // depth <= 1 → 10 } - private const string TagListingHtml = """ + private const string TagListingHtml = + """ google-cloud | Observability Labs | Elastic @@ -283,14 +337,24 @@ public async Task Root_Overview_Title_Gets_No_Redundant_SearchTitle_Suffix() [Fact] public async Task Tag_Listing_Gets_Clean_Title_And_Static_Description() { - var doc = await _extractor.ExtractAsync("https://www.elastic.co/observability-labs/blog/tag/google-cloud", TagListingHtml, null, "en", "observability-labs" -, TestContext.Current.CancellationToken); + var doc = + await _extractor.ExtractAsync( + "https://www.elastic.co/observability-labs/blog/tag/google-cloud", + TagListingHtml, + null, + "en", + "observability-labs", + TestContext.Current.CancellationToken + ); doc.Should().NotBeNull(); doc.Title.Should().Be("Articles tagged with 'Google Cloud'"); - doc.Description.Should().Be( - "Recent Observability Labs articles tagged google-cloud. A curated listing of Observability " + - "Labs blog posts, tutorials, and articles about google-cloud."); + doc.Description + .Should() + .Be( + "Recent Observability Labs articles tagged google-cloud. A curated listing of Observability " + + "Labs blog posts, tutorials, and articles about google-cloud." + ); doc.Summary.Should().Be(doc.Description); doc.Body.Should().BeEmpty(); doc.Headings.Should().BeEmpty(); @@ -299,8 +363,15 @@ public async Task Tag_Listing_Gets_Clean_Title_And_Static_Description() [Fact] public async Task Tag_Listing_Uses_Most_Recent_Article_Date_As_Published_Date() { - var doc = await _extractor.ExtractAsync("https://www.elastic.co/observability-labs/blog/tag/google-cloud", TagListingHtml, null, "en", "observability-labs" -, TestContext.Current.CancellationToken); + var doc = + await _extractor.ExtractAsync( + "https://www.elastic.co/observability-labs/blog/tag/google-cloud", + TagListingHtml, + null, + "en", + "observability-labs", + TestContext.Current.CancellationToken + ); doc.Should().NotBeNull(); doc.PublishedDate.Should().Be(DateTimeOffset.Parse("2025-08-15T00:00:00Z", System.Globalization.CultureInfo.InvariantCulture)); @@ -309,8 +380,15 @@ public async Task Tag_Listing_Uses_Most_Recent_Article_Date_As_Published_Date() [Fact] public async Task Tag_Listing_NavigationTableOfContents_Is_Not_Penalized_By_Missing_Headings() { - var doc = await _extractor.ExtractAsync("https://www.elastic.co/observability-labs/blog/tag/google-cloud", TagListingHtml, null, "en", "observability-labs" -, TestContext.Current.CancellationToken); + var doc = + await _extractor.ExtractAsync( + "https://www.elastic.co/observability-labs/blog/tag/google-cloud", + TagListingHtml, + null, + "en", + "observability-labs", + TestContext.Current.CancellationToken + ); doc.Should().NotBeNull(); // 4 URL tokens (observability-labs, blog, tag, google-cloud) → depth > 1 → 100 @@ -320,21 +398,32 @@ public async Task Tag_Listing_NavigationTableOfContents_Is_Not_Penalized_By_Miss [Fact] public async Task Author_Listing_Gets_Clean_Title_And_Static_Description() { - const string html = """ + const string html = + """ Elastic Security Labs | Security Labs | Elastic

    Elastic Security Labs

    """; - var doc = await _extractor.ExtractAsync("https://www.elastic.co/security-labs/author/elastic-security-labs", html, null, "en", "security-labs" -, TestContext.Current.CancellationToken); + var doc = + await _extractor.ExtractAsync( + "https://www.elastic.co/security-labs/author/elastic-security-labs", + html, + null, + "en", + "security-labs", + TestContext.Current.CancellationToken + ); doc.Should().NotBeNull(); doc.Title.Should().Be("Articles written by Elastic Security Labs"); - doc.Description.Should().Be( - "Articles written by Elastic Security Labs for Security Labs. A listing of Security Labs " + - "blog posts, tutorials, and articles authored by Elastic Security Labs."); + doc.Description + .Should() + .Be( + "Articles written by Elastic Security Labs for Security Labs. A listing of Security Labs " + + "blog posts, tutorials, and articles authored by Elastic Security Labs." + ); doc.Body.Should().BeEmpty(); } diff --git a/tests/Elastic.SiteSearch.Tests/MappingHashTests.cs b/tests/Elastic.SiteSearch.Tests/MappingHashTests.cs index 46796c75d4..e83bf99269 100644 --- a/tests/Elastic.SiteSearch.Tests/MappingHashTests.cs +++ b/tests/Elastic.SiteSearch.Tests/MappingHashTests.cs @@ -53,8 +53,8 @@ public class SiteExtraFieldLexicalConfig : IConfigureElasticsearch analysis; public IReadOnlyDictionary? IndexSettings => null; - public MappingsBuilder ConfigureMappings(MappingsBuilder mappings) => mappings - .AddSearchDocumentMappings(); + public MappingsBuilder ConfigureMappings(MappingsBuilder mappings) => + mappings.AddSearchDocumentMappings(); } public class SiteExtraAiFieldLexicalConfig : IConfigureElasticsearch @@ -62,8 +62,8 @@ public class SiteExtraAiFieldLexicalConfig : IConfigureElasticsearch analysis; public IReadOnlyDictionary? IndexSettings => null; - public MappingsBuilder ConfigureMappings(MappingsBuilder mappings) => mappings - .AddSearchDocumentMappings(); + public MappingsBuilder ConfigureMappings(MappingsBuilder mappings) => + mappings.AddSearchDocumentMappings(); } public class GuideExtraFieldLexicalConfig : IConfigureElasticsearch @@ -71,8 +71,8 @@ public class GuideExtraFieldLexicalConfig : IConfigureElasticsearch analysis; public IReadOnlyDictionary? IndexSettings => null; - public MappingsBuilder ConfigureMappings(MappingsBuilder mappings) => mappings - .AddSearchDocumentMappings(); + public MappingsBuilder ConfigureMappings(MappingsBuilder mappings) => + mappings.AddSearchDocumentMappings(); } public class GuideExtraAiFieldLexicalConfig : IConfigureElasticsearch @@ -80,8 +80,8 @@ public class GuideExtraAiFieldLexicalConfig : IConfigureElasticsearch analysis; public IReadOnlyDictionary? IndexSettings => null; - public MappingsBuilder ConfigureMappings(MappingsBuilder mappings) => mappings - .AddSearchDocumentMappings(); + public MappingsBuilder ConfigureMappings(MappingsBuilder mappings) => + mappings.AddSearchDocumentMappings(); } // -- Test mapping contexts -------------------------------------------------- @@ -91,11 +91,7 @@ public MappingsBuilder ConfigureMappings(Mappings /// Hash should match the real context. /// [ElasticsearchMappingContext] -[Index( - NameTemplate = "site-{type}.lexical-{env}", - DatePattern = "yyyy.MM.dd.HHmmss", - Configuration = typeof(SiteLexicalConfig) -)] +[Index(NameTemplate = "site-{type}.lexical-{env}", DatePattern = "yyyy.MM.dd.HHmmss", Configuration = typeof(SiteLexicalConfig))] public static partial class TestSiteMappingContext; /// @@ -103,47 +99,27 @@ public static partial class TestSiteMappingContext; /// Hash should match the real context. /// [ElasticsearchMappingContext] -[Index( - NameTemplate = "guide-{type}.lexical-{env}", - DatePattern = "yyyy.MM.dd.HHmmss", - Configuration = typeof(GuideLexicalConfig) -)] +[Index(NameTemplate = "guide-{type}.lexical-{env}", DatePattern = "yyyy.MM.dd.HHmmss", Configuration = typeof(GuideLexicalConfig))] public static partial class TestGuideMappingContext; /// SiteDocument + extra keyword field. Hash must differ from base. [ElasticsearchMappingContext] -[Index( - NameTemplate = "site-{type}.lexical-{env}", - DatePattern = "yyyy.MM.dd.HHmmss", - Configuration = typeof(SiteExtraFieldLexicalConfig) -)] +[Index(NameTemplate = "site-{type}.lexical-{env}", DatePattern = "yyyy.MM.dd.HHmmss", Configuration = typeof(SiteExtraFieldLexicalConfig))] public static partial class SiteExtraFieldMappingContext; /// SiteDocument + extra AI field. Hash must differ from base. [ElasticsearchMappingContext] -[Index( - NameTemplate = "site-{type}.lexical-{env}", - DatePattern = "yyyy.MM.dd.HHmmss", - Configuration = typeof(SiteExtraAiFieldLexicalConfig) -)] +[Index(NameTemplate = "site-{type}.lexical-{env}", DatePattern = "yyyy.MM.dd.HHmmss", Configuration = typeof(SiteExtraAiFieldLexicalConfig))] public static partial class SiteExtraAiFieldMappingContext; /// GuideDocument + extra keyword field. Hash must differ from base. [ElasticsearchMappingContext] -[Index( - NameTemplate = "guide-{type}.lexical-{env}", - DatePattern = "yyyy.MM.dd.HHmmss", - Configuration = typeof(GuideExtraFieldLexicalConfig) -)] +[Index(NameTemplate = "guide-{type}.lexical-{env}", DatePattern = "yyyy.MM.dd.HHmmss", Configuration = typeof(GuideExtraFieldLexicalConfig))] public static partial class GuideExtraFieldMappingContext; /// GuideDocument + extra AI field. Hash must differ from base. [ElasticsearchMappingContext] -[Index( - NameTemplate = "guide-{type}.lexical-{env}", - DatePattern = "yyyy.MM.dd.HHmmss", - Configuration = typeof(GuideExtraAiFieldLexicalConfig) -)] +[Index(NameTemplate = "guide-{type}.lexical-{env}", DatePattern = "yyyy.MM.dd.HHmmss", Configuration = typeof(GuideExtraAiFieldLexicalConfig))] public static partial class GuideExtraAiFieldMappingContext; // -- Tests ------------------------------------------------------------------ @@ -154,47 +130,45 @@ public class MappingHashTests [Fact] public void SiteDocument_SameConfig_ProducesSameHash() => - TestSiteMappingContext.SiteDocument.Hash - .Should().Be(SiteMappingContext.SiteDocument.Hash); + TestSiteMappingContext.SiteDocument.Hash.Should().Be(SiteMappingContext.SiteDocument.Hash); [Fact] public void GuideDocument_SameConfig_ProducesSameHash() => - TestGuideMappingContext.GuideDocument.Hash - .Should().Be(GuideMappingContext.GuideDocument.Hash); + TestGuideMappingContext.GuideDocument.Hash.Should().Be(GuideMappingContext.GuideDocument.Hash); // ── Adding a keyword field changes the hash ─────────────────────────────── [Fact] public void SiteDocument_ExtraField_ChangesHash() => - SiteExtraFieldMappingContext.SiteDocumentWithExtraField.Hash - .Should().NotBe(SiteMappingContext.SiteDocument.Hash); + SiteExtraFieldMappingContext.SiteDocumentWithExtraField.Hash.Should().NotBe(SiteMappingContext.SiteDocument.Hash); [Fact] public void GuideDocument_ExtraField_ChangesHash() => - GuideExtraFieldMappingContext.GuideDocumentWithExtraField.Hash - .Should().NotBe(GuideMappingContext.GuideDocument.Hash); + GuideExtraFieldMappingContext.GuideDocumentWithExtraField.Hash.Should().NotBe(GuideMappingContext.GuideDocument.Hash); // ── Adding an AI field changes the hash ─────────────────────────────────── [Fact] public void SiteDocument_ExtraAiField_ChangesHash() => - SiteExtraAiFieldMappingContext.SiteDocumentWithExtraAiField.Hash - .Should().NotBe(SiteMappingContext.SiteDocument.Hash); + SiteExtraAiFieldMappingContext.SiteDocumentWithExtraAiField.Hash.Should().NotBe(SiteMappingContext.SiteDocument.Hash); [Fact] public void GuideDocument_ExtraAiField_ChangesHash() => - GuideExtraAiFieldMappingContext.GuideDocumentWithExtraAiField.Hash - .Should().NotBe(GuideMappingContext.GuideDocument.Hash); + GuideExtraAiFieldMappingContext.GuideDocumentWithExtraAiField.Hash.Should().NotBe(GuideMappingContext.GuideDocument.Hash); // ── Extra field vs extra AI field are different from each other ──────────── [Fact] public void SiteDocument_ExtraField_DiffersFrom_ExtraAiField() => - SiteExtraFieldMappingContext.SiteDocumentWithExtraField.Hash - .Should().NotBe(SiteExtraAiFieldMappingContext.SiteDocumentWithExtraAiField.Hash); + SiteExtraFieldMappingContext.SiteDocumentWithExtraField + .Hash + .Should() + .NotBe(SiteExtraAiFieldMappingContext.SiteDocumentWithExtraAiField.Hash); [Fact] public void GuideDocument_ExtraField_DiffersFrom_ExtraAiField() => - GuideExtraFieldMappingContext.GuideDocumentWithExtraField.Hash - .Should().NotBe(GuideExtraAiFieldMappingContext.GuideDocumentWithExtraAiField.Hash); + GuideExtraFieldMappingContext.GuideDocumentWithExtraField + .Hash + .Should() + .NotBe(GuideExtraAiFieldMappingContext.GuideDocumentWithExtraAiField.Hash); } diff --git a/tests/Elastic.SiteSearch.Tests/MappingStructureTests.cs b/tests/Elastic.SiteSearch.Tests/MappingStructureTests.cs index d59128c0a6..92f1dde23c 100644 --- a/tests/Elastic.SiteSearch.Tests/MappingStructureTests.cs +++ b/tests/Elastic.SiteSearch.Tests/MappingStructureTests.cs @@ -29,7 +29,12 @@ public void SiteDocument_GetId_ReturnsUrl() public void GuideDocument_GetId_ReturnsUrl() { var ctx = GuideMappingContext.GuideDocument.CreateContext(type: "en", env: "test"); - var doc = new GuideDocument { Title = "t", SearchTitle = "t", Path = "https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html" }; + var doc = new GuideDocument + { + Title = "t", + SearchTitle = "t", + Path = "https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html" + }; ctx.GetId!(doc).Should().Be("https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html"); } @@ -77,8 +82,7 @@ public void SiteDocument_UrlField_IsKeyword() { var json = SiteMappingContext.SiteDocument.GetMappingJson(); using var doc = JsonDocument.Parse(json); - doc.RootElement.GetProperty("properties").GetProperty("path") - .GetProperty("type").GetString().Should().Be("keyword"); + doc.RootElement.GetProperty("properties").GetProperty("path").GetProperty("type").GetString().Should().Be("keyword"); } [Fact] @@ -86,8 +90,7 @@ public void GuideDocument_UrlField_IsKeyword() { var json = GuideMappingContext.GuideDocument.GetMappingJson(); using var doc = JsonDocument.Parse(json); - doc.RootElement.GetProperty("properties").GetProperty("path") - .GetProperty("type").GetString().Should().Be("keyword"); + doc.RootElement.GetProperty("properties").GetProperty("path").GetProperty("type").GetString().Should().Be("keyword"); } // ── content_type: [Keyword] on base ────────────────────────────────────── @@ -97,8 +100,7 @@ public void SiteDocument_ContentTypeField_IsKeyword() { var json = SiteMappingContext.SiteDocument.GetMappingJson(); using var doc = JsonDocument.Parse(json); - doc.RootElement.GetProperty("properties").GetProperty("content_type") - .GetProperty("type").GetString().Should().Be("keyword"); + doc.RootElement.GetProperty("properties").GetProperty("content_type").GetProperty("type").GetString().Should().Be("keyword"); } // ── tags: copy_to target for content_type/section ───── @@ -108,8 +110,7 @@ public void SiteDocument_ContentTypeField_CopiesToContentTags() { var json = SiteMappingContext.SiteDocument.GetMappingJson(); using var doc = JsonDocument.Parse(json); - doc.RootElement.GetProperty("properties").GetProperty("content_type") - .GetProperty("copy_to").GetString().Should().Be("tags"); + doc.RootElement.GetProperty("properties").GetProperty("content_type").GetProperty("copy_to").GetString().Should().Be("tags"); } [Fact] @@ -117,8 +118,7 @@ public void SiteDocument_NavigationSectionField_CopiesToContentTags() { var json = SiteMappingContext.SiteDocument.GetMappingJson(); using var doc = JsonDocument.Parse(json); - doc.RootElement.GetProperty("properties").GetProperty("section") - .GetProperty("copy_to").GetString().Should().Be("tags"); + doc.RootElement.GetProperty("properties").GetProperty("section").GetProperty("copy_to").GetString().Should().Be("tags"); } [Fact] @@ -138,8 +138,7 @@ public void SiteDocument_ContentTierField_IsKeyword() { var json = SiteMappingContext.SiteDocument.GetMappingJson(); using var doc = JsonDocument.Parse(json); - doc.RootElement.GetProperty("properties").GetProperty("content_tier") - .GetProperty("type").GetString().Should().Be("keyword"); + doc.RootElement.GetProperty("properties").GetProperty("content_tier").GetProperty("type").GetString().Should().Be("keyword"); } [Fact] @@ -156,8 +155,7 @@ public void SiteDocument_HashField_IsKeyword() { var json = SiteMappingContext.SiteDocument.GetMappingJson(); using var doc = JsonDocument.Parse(json); - doc.RootElement.GetProperty("properties").GetProperty("hash") - .GetProperty("type").GetString().Should().Be("keyword"); + doc.RootElement.GetProperty("properties").GetProperty("hash").GetProperty("type").GetString().Should().Be("keyword"); } // ── Title multi-fields from AddSearchDocumentMappings ──────────────────── @@ -228,7 +226,9 @@ public void SiteDocument_UrlField_HasMatchAndPrefixMultiFields() var urlFields = doc.RootElement.GetProperty("properties").GetProperty("path").GetProperty("fields"); urlFields.TryGetProperty("match", out _).Should().BeTrue("path.match is configured in AddCommonTitleMappings"); - urlFields.TryGetProperty("prefix", out _).Should().BeTrue("path.prefix (hierarchy_analyzer) is configured in AddCommonTitleMappings"); + urlFields.TryGetProperty("prefix", out _) + .Should() + .BeTrue("path.prefix (hierarchy_analyzer) is configured in AddCommonTitleMappings"); urlFields.GetProperty("prefix").GetProperty("analyzer").GetString().Should().Be("hierarchy_analyzer"); } @@ -249,7 +249,11 @@ public void SiteDocument_NavigationTableOfContents_IsRankFeatureWithNegativeImpa { var json = SiteMappingContext.SiteDocument.GetMappingJson(); using var doc = JsonDocument.Parse(json); - var toc = doc.RootElement.GetProperty("properties").GetProperty("navigation").GetProperty("properties").GetProperty("table_of_contents"); + var toc = doc.RootElement + .GetProperty("properties") + .GetProperty("navigation") + .GetProperty("properties") + .GetProperty("table_of_contents"); toc.GetProperty("type").GetString().Should().Be("rank_feature"); toc.GetProperty("positive_score_impact").GetBoolean().Should().BeFalse(); } @@ -306,20 +310,16 @@ public void SiteDocument_LexicalVariant_DoesNotHaveSemanticTextField() // ── Field name constants match JSON property names ──────────────────────── [Fact] - public void SiteDocument_Fields_UrlMatchesJsonPropertyName() => - SiteMappingContext.SiteDocument.Fields.Path.Should().Be("path"); + public void SiteDocument_Fields_UrlMatchesJsonPropertyName() => SiteMappingContext.SiteDocument.Fields.Path.Should().Be("path"); [Fact] - public void SiteDocument_Fields_TitleMatchesJsonPropertyName() => - SiteMappingContext.SiteDocument.Fields.Title.Should().Be("title"); + public void SiteDocument_Fields_TitleMatchesJsonPropertyName() => SiteMappingContext.SiteDocument.Fields.Title.Should().Be("title"); [Fact] - public void SiteDocument_Fields_HashMatchesJsonPropertyName() => - SiteMappingContext.SiteDocument.Fields.Hash.Should().Be("hash"); + public void SiteDocument_Fields_HashMatchesJsonPropertyName() => SiteMappingContext.SiteDocument.Fields.Hash.Should().Be("hash"); [Fact] - public void GuideDocument_Fields_UrlMatchesJsonPropertyName() => - GuideMappingContext.GuideDocument.Fields.Path.Should().Be("path"); + public void GuideDocument_Fields_UrlMatchesJsonPropertyName() => GuideMappingContext.GuideDocument.Fields.Path.Should().Be("path"); // ── parents: shared topology declared once in SharedMappingConfig ──────── diff --git a/tests/Elastic.SiteSearch.Tests/SharedAnalysisFactoryTests.cs b/tests/Elastic.SiteSearch.Tests/SharedAnalysisFactoryTests.cs index 169de3925a..f12dfe4078 100644 --- a/tests/Elastic.SiteSearch.Tests/SharedAnalysisFactoryTests.cs +++ b/tests/Elastic.SiteSearch.Tests/SharedAnalysisFactoryTests.cs @@ -53,8 +53,12 @@ public void SymbolRewriteCharFilter_IsPatternReplaceToDotnet() public void SynonymsFixedAnalyzer_HasMorphologyOverrideBeforeKstem() { var json = BuildAnalysisJson(); - var filters = json.GetProperty("analyzer").GetProperty("synonyms_fixed_analyzer") - .GetProperty("filter").EnumerateArray().Select(e => e.GetString()).ToArray(); + var filters = json.GetProperty("analyzer") + .GetProperty("synonyms_fixed_analyzer") + .GetProperty("filter") + .EnumerateArray() + .Select(e => e.GetString()) + .ToArray(); filters.Should().ContainInOrder("lowercase", "morphology_override_filter", "synonyms_fixed_filter", "kstem"); } @@ -62,8 +66,12 @@ public void SynonymsFixedAnalyzer_HasMorphologyOverrideBeforeKstem() public void SynonymsAnalyzer_HasMorphologyOverrideBeforeKstem() { var json = BuildAnalysisJson(); - var filters = json.GetProperty("analyzer").GetProperty("synonyms_analyzer") - .GetProperty("filter").EnumerateArray().Select(e => e.GetString()).ToArray(); + var filters = json.GetProperty("analyzer") + .GetProperty("synonyms_analyzer") + .GetProperty("filter") + .EnumerateArray() + .Select(e => e.GetString()) + .ToArray(); filters.Should().ContainInOrder("lowercase", "morphology_override_filter", "synonyms_filter", "kstem"); } diff --git a/tests/Mcp.Remote.Tests/DocumentToolsTests.cs b/tests/Mcp.Remote.Tests/DocumentToolsTests.cs index cde3a22347..2615398680 100644 --- a/tests/Mcp.Remote.Tests/DocumentToolsTests.cs +++ b/tests/Mcp.Remote.Tests/DocumentToolsTests.cs @@ -50,8 +50,8 @@ public async Task GetDocumentByUrl_OmitsSourceUrlWhenNull() var json = await tools.GetDocumentByUrl("/docs/some-page", cancellationToken: TestContext.Current.CancellationToken); using var doc = JsonDocument.Parse(json); - var hasNonNullSourceUrl = doc.RootElement.TryGetProperty("sourceUrl", out var sourceUrl) - && sourceUrl.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined; + var hasNonNullSourceUrl = doc.RootElement.TryGetProperty("sourceUrl", out var sourceUrl) && + sourceUrl.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined; hasNonNullSourceUrl.Should().BeFalse(); var response = JsonSerializer.Deserialize(json, McpJsonContext.Default.DocumentResponse); response.Should().NotBeNull(); @@ -60,8 +60,7 @@ public async Task GetDocumentByUrl_OmitsSourceUrlWhenNull() private sealed class StubDocumentGateway(DocumentResult? result) : IDocumentGateway { - public Task GetByUrlAsync(string url, CancellationToken ct = default) => - Task.FromResult(result); + public Task GetByUrlAsync(string url, CancellationToken ct = default) => Task.FromResult(result); public Task GetStructureAsync(string url, CancellationToken ct = default) => Task.FromResult(null); diff --git a/tests/Mcp.Remote.Tests/McpServerInstructionTests.cs b/tests/Mcp.Remote.Tests/McpServerInstructionTests.cs index 04b629d6b6..65b8370a1c 100644 --- a/tests/Mcp.Remote.Tests/McpServerInstructionTests.cs +++ b/tests/Mcp.Remote.Tests/McpServerInstructionTests.cs @@ -2,8 +2,8 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using Elastic.Documentation.Mcp.Remote; using AwesomeAssertions; +using Elastic.Documentation.Mcp.Remote; namespace Mcp.Remote.Tests; @@ -88,9 +88,7 @@ public void Resolve_WithUnknownProfile_Throws() { var act = () => McpServerProfile.Resolve("unknown"); - act.Should().Throw() - .WithMessage("*Unknown MCP server profile*") - .WithParameterName("name"); + act.Should().Throw().WithMessage("*Unknown MCP server profile*").WithParameterName("name"); } [Fact] @@ -98,7 +96,8 @@ public void PublicProfile_ComposesExactInstructions() { var instructions = McpServerProfile.Public.ComposeServerInstructions(); - var expected = """ + var expected = + """ Use this server to search, retrieve, and analyze Elastic product documentation published at elastic.co/docs. @@ -125,7 +124,8 @@ public void InternalProfile_ComposesExactInstructions() { var instructions = McpServerProfile.Internal.ComposeServerInstructions(); - var expected = """ + var expected = + """ Use this server to search and retrieve Elastic internal documentation: team processes, run books, architecture, and other internal knowledge. @@ -146,8 +146,7 @@ public void InternalProfile_ComposesExactInstructions() } private static List ExtractBullets(string instructions) => - instructions - .Split('\n') + instructions.Split('\n') .Where(l => l.TrimStart().StartsWith("- ", StringComparison.Ordinal)) .Select(l => l.TrimStart()[2..]) .ToList(); diff --git a/tests/Mcp.Remote.Tests/McpToolTelemetryTests.cs b/tests/Mcp.Remote.Tests/McpToolTelemetryTests.cs index 2655e6bb9f..d36e448f3f 100644 --- a/tests/Mcp.Remote.Tests/McpToolTelemetryTests.cs +++ b/tests/Mcp.Remote.Tests/McpToolTelemetryTests.cs @@ -3,10 +3,10 @@ // See the LICENSE file in the project root for more information using System.Diagnostics; +using AwesomeAssertions; using Elastic.Documentation.Configuration; using Elastic.Documentation.Mcp.Remote; using Elastic.Documentation.Mcp.Remote.Telemetry; -using AwesomeAssertions; namespace Mcp.Remote.Tests; @@ -17,9 +17,11 @@ public void ResolveToolName_UsesProfilePlaceholders() { var template = "find_{scope}related_{resource}"; var profile = McpServerProfile.Resolve(SystemEnvironmentVariables.Instance.McpServerProfile); - var expected = template - .Replace("{resource}", profile.ResourceNoun, StringComparison.Ordinal) - .Replace("{scope}", profile.ScopePrefix, StringComparison.Ordinal); + var expected = template.Replace("{resource}", profile.ResourceNoun, StringComparison.Ordinal).Replace( + "{scope}", + profile.ScopePrefix, + StringComparison.Ordinal + ); var resolved = McpToolTelemetry.ResolveToolName(template); @@ -33,12 +35,10 @@ public void SetPayloadMetadata_SetsArgCountKeysAndStringLengths() using var activity = McpToolTelemetry.StartActivity("test_tool"); activity.Should().NotBeNull(); - var metadata = McpToolTelemetry.SetPayloadMetadata(activity, new Dictionary - { - ["query"] = "cluster setup", - ["pageNumber"] = 2, - ["topic"] = "observability" - }); + var metadata = McpToolTelemetry.SetPayloadMetadata( + activity, + new Dictionary { ["query"] = "cluster setup", ["pageNumber"] = 2, ["topic"] = "observability" } + ); metadata.ArgCount.Should().Be(3); metadata.ArgKeys.Should().Be("pageNumber,query,topic"); diff --git a/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs b/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs index fca9bab7b9..080d04d0d0 100644 --- a/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs +++ b/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs @@ -21,7 +21,8 @@ public class ComplexSiteNavigationTests(ITestOutputHelper output) public async Task MultipleSectionsFromSameRepository_UseContentHashesAndCacheByRoot() { // language=yaml - var siteNavYaml = """ + var siteNavYaml = + """ toc: - toc: observability:// path_prefix: / @@ -39,23 +40,27 @@ public async Task MultipleSectionsFromSameRepository_UseContentHashesAndCacheByR var docset = DocumentationSetFile.LoadAndResolve( context.Collector, fileSystem.FileInfo.New($"{repositoryPath}/docs/docset.yml"), - new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); documentationSets.Add( - new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance)); + new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance) + ); } - var siteContext = SiteNavigationTestFixture.CreateAssemblerContext( - fileSystem, "/checkouts/current/observability", output); + var siteContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/observability", output); var siteNavigation = new SiteNavigation( - SiteNavigationFile.Deserialize(siteNavYaml), siteContext, documentationSets, sitePrefix: null); + SiteNavigationFile.Deserialize(siteNavYaml), + siteContext, + documentationSets, + sitePrefix: null + ); var sections = siteNavigation.NavigationItems .OfType>() .Where(section => section.Identifier.Scheme == "platform") .ToArray(); sections.Should().HaveCount(2); - var writer = new GlobalNavigationHtmlWriter( - NullLoggerFactory.Instance, siteNavigation, siteContext.Collector); + var writer = new GlobalNavigationHtmlWriter(NullLoggerFactory.Instance, siteNavigation, siteContext.Collector); var first = await writer.RenderNavigation(sections[0], sections[0].Index, TestContext.Current.CancellationToken); var second = await writer.RenderNavigation(sections[1], sections[1].Index, TestContext.Current.CancellationToken); var firstAgain = await writer.RenderNavigation(sections[0], sections[0].Index, TestContext.Current.CancellationToken); @@ -68,7 +73,8 @@ public async Task MultipleSectionsFromSameRepository_UseContentHashesAndCacheByR public void ComplexNavigationWithMultipleNestedTocsAppliesPathPrefixToRootUrls() { // language=yaml - var siteNavYaml = """ + var siteNavYaml = + """ toc: - toc: observability:// path_prefix: /serverless/observability @@ -102,7 +108,11 @@ public void ComplexNavigationWithMultipleNestedTocsAppliesPathPrefixToRootUrls() ? $"{repo.FullName}/docs/docset.yml" : $"{repo.FullName}/docs/_docset.yml"; - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); + var docset = DocumentationSetFile.LoadAndResolve( + context.Collector, + fileSystem.FileInfo.New(docsetPath), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); documentationSets.Add(navigation); @@ -164,7 +174,8 @@ public void ComplexNavigationWithMultipleNestedTocsAppliesPathPrefixToRootUrls() public void DeeplyNestedNavigationMaintainsPathPrefixThroughoutHierarchy() { // language=YAML - test without specifying children for nested TOCs - var siteNavYaml = """ + var siteNavYaml = + """ toc: - toc: platform:// path_prefix: /docs/platform @@ -179,8 +190,11 @@ public void DeeplyNestedNavigationMaintainsPathPrefixThroughoutHierarchy() var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var platformContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); - var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, - fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); + var platformDocset = DocumentationSetFile.LoadAndResolve( + platformContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); var documentationSets = new List { @@ -207,15 +221,15 @@ public void DeeplyNestedNavigationMaintainsPathPrefixThroughoutHierarchy() // Walk through the entire tree and verify every single URL starts with a path prefix var allUrls = CollectAllUrls(platform.NavigationItems); allUrls.Should().NotBeEmpty(); - allUrls.Should().OnlyContain(url => url.StartsWith("/docs/platform/"), - "all URLs in platform should start with /docs/platform"); + allUrls.Should().OnlyContain(url => url.StartsWith("/docs/platform/"), "all URLs in platform should start with /docs/platform"); } [Fact] public void FileNavigationLeafUrlsReflectPathPrefixInDeeplyNestedStructures() { // language=YAML - don't specify children so we can access the actual file leaves - var siteNavYaml = """ + var siteNavYaml = + """ toc: - toc: platform:// path_prefix: /platform @@ -230,8 +244,11 @@ public void FileNavigationLeafUrlsReflectPathPrefixInDeeplyNestedStructures() var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var platformContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); - var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, - fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); + var platformDocset = DocumentationSetFile.LoadAndResolve( + platformContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); var documentationSets = new List { @@ -260,13 +277,18 @@ public void FileNavigationLeafUrlsReflectPathPrefixInDeeplyNestedStructures() // Verify every single file leaf has the correct path prefix foreach (var fileLeaf in fileLeaves) { - fileLeaf.Url.Should().StartWith("/platform", - $"file '{fileLeaf.NavigationTitle}' should have URL starting with /platform but got '{fileLeaf.Url}'"); + fileLeaf.Url + .Should() + .StartWith( + "/platform", + $"file '{fileLeaf.NavigationTitle}' should have URL starting with /platform but got '{fileLeaf.Url}'" + ); } // Verify at least one specific file to ensure we're testing real data - var indexFile = fileLeaves.OfType>() - .FirstOrDefault(f => f.FileInfo.FullName.EndsWith(".md", StringComparison.OrdinalIgnoreCase)); + var indexFile = fileLeaves.OfType>().FirstOrDefault( + f => f.FileInfo.FullName.EndsWith(".md", StringComparison.OrdinalIgnoreCase) + ); indexFile.Should().NotBeNull(); indexFile.Url.Should().StartWith("/platform"); } @@ -275,7 +297,8 @@ public void FileNavigationLeafUrlsReflectPathPrefixInDeeplyNestedStructures() public void FolderNavigationWithinNestedTocsHasCorrectPathPrefix() { // language=YAML - don't specify children so we can access the actual folders - var siteNavYaml = """ + var siteNavYaml = + """ toc: - toc: platform:// path_prefix: /platform/cloud @@ -290,18 +313,19 @@ public void FolderNavigationWithinNestedTocsHasCorrectPathPrefix() var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml); var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); - var platformContext = SiteNavigationTestFixture.CreateContext( - fileSystem, "/checkouts/current/platform", output); - var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, - fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); + var platformContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); + var platformDocset = DocumentationSetFile.LoadAndResolve( + platformContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); var documentationSets = new List { new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance) }; - var siteContext = SiteNavigationTestFixture.CreateContext( - fileSystem, "/checkouts/current/platform", output); + var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix: null); @@ -317,17 +341,16 @@ public void FolderNavigationWithinNestedTocsHasCorrectPathPrefix() cloudGuide.Should().BeOfType>(); // cloud-guide should have folders (index, aws, azure) - var folders = cloudGuide.NavigationItems - .OfType>() - .ToList(); + var folders = cloudGuide.NavigationItems.OfType>().ToList(); folders.Should().NotBeEmpty("cloud-guide should contain folders"); // Verify each folder and all its contents have a correct path prefix foreach (var folder in folders) { - folder.Url.Should().StartWith("/platform/cloud", - $"folder '{folder.NavigationTitle}' should have URL starting with /platform/cloud"); + folder.Url + .Should() + .StartWith("/platform/cloud", $"folder '{folder.NavigationTitle}' should have URL starting with /platform/cloud"); // Verify all items within the folder AssertAllUrlsStartWith(folder.NavigationItems, "/platform/cloud"); @@ -336,8 +359,12 @@ public void FolderNavigationWithinNestedTocsHasCorrectPathPrefix() var filesInFolder = CollectAllFileLeaves(folder.NavigationItems); foreach (var file in filesInFolder) { - file.Url.Should().StartWith("/platform/cloud", - $"file '{file.NavigationTitle}' in folder '{folder.NavigationTitle}' should have URL starting with /platform/cloud"); + file.Url + .Should() + .StartWith( + "/platform/cloud", + $"file '{file.NavigationTitle}' in folder '{folder.NavigationTitle}' should have URL starting with /platform/cloud" + ); } } } @@ -349,8 +376,12 @@ private static void AssertAllUrlsStartWith(IEnumerable items, s { foreach (var item in items) { - item.Url.Should().StartWith(expectedPrefix, - $"item '{item.NavigationTitle}' should have URL starting with '{expectedPrefix}' but got '{item.Url}'"); + item.Url + .Should() + .StartWith( + expectedPrefix, + $"item '{item.NavigationTitle}' should have URL starting with '{expectedPrefix}' but got '{item.Url}'" + ); if (item is INodeNavigationItem nodeItem) AssertAllUrlsStartWith(nodeItem.NavigationItems, expectedPrefix); diff --git a/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs b/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs index 8be34fddb3..202d3a6e30 100644 --- a/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs +++ b/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs @@ -19,11 +19,17 @@ public void DocumentationSetNavigationCollectsRootIdentifier() var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); // Test platform repository - var platformContext = SiteNavigationTestFixture.CreateContext( - fileSystem, "/checkouts/current/platform", output); + var platformContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); var platformDocset = DocumentationSetFile.LoadAndResolve( - platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); + platformContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + var platformNav = new DocumentationSetNavigation( + platformDocset, + platformContext, + GenericDocumentationFileFactory.Instance + ); // Root identifier should be :// platformNav.Identifier.Should().Be(new Uri("platform://")); @@ -37,16 +43,22 @@ public void DocumentationSetNavigationCollectsNestedTocIdentifiers() // Test platform repository with nested TOCs var platformContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); - var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, platformContext.ConfigurationPath, new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); + var platformDocset = DocumentationSetFile.LoadAndResolve( + platformContext.Collector, + platformContext.ConfigurationPath, + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + var platformNav = new DocumentationSetNavigation( + platformDocset, + platformContext, + GenericDocumentationFileFactory.Instance + ); // Should collect identifiers from nested TOCs - platformNav.TableOfContentNodes.Keys.Should().Contain( - [ - new Uri("platform://"), - new Uri("platform://deployment-guide"), - new Uri("platform://cloud-guide") - ]); + platformNav.TableOfContentNodes + .Keys + .Should() + .Contain([new Uri("platform://"), new Uri("platform://deployment-guide"), new Uri("platform://cloud-guide")]); platformNav.TableOfContentNodes.Should().HaveCount(3); } @@ -58,8 +70,16 @@ public void DocumentationSetNavigationWithSimpleStructure() // Test observability repository (no nested TOCs) var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); + var observabilityDocset = DocumentationSetFile.LoadAndResolve( + observabilityContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + var observabilityNav = new DocumentationSetNavigation( + observabilityDocset, + observabilityContext, + GenericDocumentationFileFactory.Instance + ); // Should only have root identifier observabilityNav.TableOfContentNodes.Keys.Should().Contain(new Uri("observability://")); @@ -72,11 +92,17 @@ public void TableOfContentsNavigationHasCorrectIdentifier() var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); // Test platform repository with nested TOCs - var platformContext = SiteNavigationTestFixture.CreateContext( - fileSystem, "/checkouts/current/platform", output); + var platformContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); var platformDocset = DocumentationSetFile.LoadAndResolve( - platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); + platformContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + var platformNav = new DocumentationSetNavigation( + platformDocset, + platformContext, + GenericDocumentationFileFactory.Instance + ); // Get the deployment-guide TOC var deploymentGuide = platformNav.NavigationItems.ElementAt(0) as TableOfContentsNavigation; @@ -95,17 +121,29 @@ public void MultipleDocumentationSetsHaveDistinctIdentifiers() var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); // Create multiple documentation sets - var platformContext = SiteNavigationTestFixture.CreateContext( - fileSystem, "/checkouts/current/platform", output); + var platformContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); var platformDocset = DocumentationSetFile.LoadAndResolve( - platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); + platformContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + var platformNav = new DocumentationSetNavigation( + platformDocset, + platformContext, + GenericDocumentationFileFactory.Instance + ); - var observabilityContext = SiteNavigationTestFixture.CreateContext( - fileSystem, "/checkouts/current/observability", output); + var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); var observabilityDocset = DocumentationSetFile.LoadAndResolve( - observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); + observabilityContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + var observabilityNav = new DocumentationSetNavigation( + observabilityDocset, + observabilityContext, + GenericDocumentationFileFactory.Instance + ); // Each should have its own set of identifiers platformNav.TableOfContentNodes.Keys.Should().NotIntersectWith(observabilityNav.TableOfContentNodes.Keys); diff --git a/tests/Navigation.Tests/Assembler/IslandSiteNavigationTests.cs b/tests/Navigation.Tests/Assembler/IslandSiteNavigationTests.cs index b19a562209..a0c482dc54 100644 --- a/tests/Navigation.Tests/Assembler/IslandSiteNavigationTests.cs +++ b/tests/Navigation.Tests/Assembler/IslandSiteNavigationTests.cs @@ -23,7 +23,8 @@ public void TopLevelEntries_AreIslands_WithoutDeclaringIt() // A plain - toc: entry in navigation.yml with no island: property // must still be marked as an island by SiteNavigation's constructor. // language=yaml - var siteNavYaml = """ + var siteNavYaml = + """ toc: - toc: observability:// path_prefix: observability @@ -33,15 +34,18 @@ public void TopLevelEntries_AreIslands_WithoutDeclaringIt() var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var obsContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/observability", output); - var obsDocset = DocumentationSetFile.LoadAndResolve(obsContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); + var obsDocset = DocumentationSetFile.LoadAndResolve( + obsContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); var obsNav = new DocumentationSetNavigation(obsDocset, obsContext, GenericDocumentationFileFactory.Instance); var documentationSets = new List { obsNav }; var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); var navigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix: null); - var obsNode = navigation.NavigationItems.ElementAt(0) - .Should().BeOfType>().Subject; + var obsNode = navigation.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; // No island: declared in either docset.yml or navigation.yml obsNode.IsIsland.Should().BeTrue("SiteNavigation marks every top-level section as an island implicitly"); @@ -55,7 +59,8 @@ public void TopLevelEntries_AreIslands_WithoutDeclaringIt() public void NavigationYamlIsland_MarksResolvedNode() { // language=yaml - var siteNavYaml = """ + var siteNavYaml = + """ toc: - toc: observability:// path_prefix: observability @@ -66,15 +71,18 @@ public void NavigationYamlIsland_MarksResolvedNode() var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var obsContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/observability", output); - var obsDocset = DocumentationSetFile.LoadAndResolve(obsContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); + var obsDocset = DocumentationSetFile.LoadAndResolve( + obsContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); var obsNav = new DocumentationSetNavigation(obsDocset, obsContext, GenericDocumentationFileFactory.Instance); var documentationSets = new List { obsNav }; var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); var navigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix: null); - var obsNode = navigation.NavigationItems.ElementAt(0) - .Should().BeOfType>().Subject; + var obsNode = navigation.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; // The node was marked as an island from navigation.yml obsNode.IsIsland.Should().BeTrue("navigation.yml declared island: true"); @@ -90,7 +98,8 @@ public void NavigationYamlIsland_MarksResolvedNode() public void DocsetRootIsland_IsAnIsland_InAssemblerBuild() { // language=yaml - var siteNavYaml = """ + var siteNavYaml = + """ toc: - toc: observability:// path_prefix: observability @@ -100,8 +109,10 @@ public void DocsetRootIsland_IsAnIsland_InAssemblerBuild() var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); // Inject island: true into the observability docset.yml - fileSystem.AddFile("/checkouts/current/observability/docs/docset.yml", new MockFileData( - """ + fileSystem.AddFile( + "/checkouts/current/observability/docs/docset.yml", + new MockFileData( + """ project: observability island: true toc: @@ -116,13 +127,23 @@ public void DocsetRootIsland_IsAnIsland_InAssemblerBuild() - file: logs.md - file: metrics.md - file: traces.md - """)); + """ + ) + ); var obsContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/observability", output); - var obsDocset = DocumentationSetFile.LoadAndResolve(obsContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); + var obsDocset = DocumentationSetFile.LoadAndResolve( + obsContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); // In isolated build: IsIsland is stored but RendersAsIsland() is false (no parent) - var isolatedNav = new DocumentationSetNavigation(obsDocset, obsContext, GenericDocumentationFileFactory.Instance); + var isolatedNav = new DocumentationSetNavigation( + obsDocset, + obsContext, + GenericDocumentationFileFactory.Instance + ); isolatedNav.IsIsland.Should().BeTrue(); isolatedNav.RendersAsIsland().Should().BeFalse("no parent yet in isolated build"); @@ -131,8 +152,7 @@ public void DocsetRootIsland_IsAnIsland_InAssemblerBuild() var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); var navigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix: null); - var obsNode = navigation.NavigationItems.ElementAt(0) - .Should().BeOfType>().Subject; + var obsNode = navigation.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; obsNode.RendersAsIsland().Should().BeTrue("SiteNavigation gave it a parent"); } @@ -145,15 +165,18 @@ public void NavigationYamlIsland_DoesNotClearContentSetIsland() { // Set up: content-set has island: true, navigation.yml does NOT — node should still be island // language=yaml - var siteNavNoIsland = """ + var siteNavNoIsland = + """ toc: - toc: observability:// path_prefix: observability """; var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); - fileSystem.AddFile("/checkouts/current/observability/docs/docset.yml", new MockFileData( - """ + fileSystem.AddFile( + "/checkouts/current/observability/docs/docset.yml", + new MockFileData( + """ project: observability island: true toc: @@ -168,17 +191,27 @@ public void NavigationYamlIsland_DoesNotClearContentSetIsland() - file: logs.md - file: metrics.md - file: traces.md - """)); + """ + ) + ); var obsContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/observability", output); - var obsDocset = DocumentationSetFile.LoadAndResolve(obsContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); + var obsDocset = DocumentationSetFile.LoadAndResolve( + obsContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); var obsNav = new DocumentationSetNavigation(obsDocset, obsContext, GenericDocumentationFileFactory.Instance); var documentationSets = new List { obsNav }; var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var navigation = new SiteNavigation(SiteNavigationFile.Deserialize(siteNavNoIsland), siteContext, documentationSets, sitePrefix: null); - - var obsNode = navigation.NavigationItems.ElementAt(0) - .Should().BeOfType>().Subject; + var navigation = new SiteNavigation( + SiteNavigationFile.Deserialize(siteNavNoIsland), + siteContext, + documentationSets, + sitePrefix: null + ); + + var obsNode = navigation.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; // Content-set declared island: true; navigation.yml didn't; still an island (OR semantics) obsNode.IsIsland.Should().BeTrue("content-set set island: true; navigation.yml can't remove it"); obsNode.RendersAsIsland().Should().BeTrue(); diff --git a/tests/Navigation.Tests/Assembler/SectionNavigationTests.cs b/tests/Navigation.Tests/Assembler/SectionNavigationTests.cs index f798570e71..bdb465d438 100644 --- a/tests/Navigation.Tests/Assembler/SectionNavigationTests.cs +++ b/tests/Navigation.Tests/Assembler/SectionNavigationTests.cs @@ -23,8 +23,10 @@ public class SectionNavigationTests(ITestOutputHelper output) // Helpers // ────────────────────────────────────────────────────────────── - private static (SiteNavigation, DocumentationSetNavigation, DocumentationSetNavigation) - BuildTwoChildSection(ITestOutputHelper output, string siteNavYaml) + private static (SiteNavigation, DocumentationSetNavigation, DocumentationSetNavigation) BuildTwoChildSection( + ITestOutputHelper output, + string siteNavYaml + ) { var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml); var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); @@ -33,15 +35,21 @@ private static (SiteNavigation, DocumentationSetNavigation, var obsDocset = DocumentationSetFile.LoadAndResolve( obsCtx.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), - new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); var obsNav = new DocumentationSetNavigation(obsDocset, obsCtx, GenericDocumentationFileFactory.Instance); var searchCtx = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/serverless-search", output); var searchDocset = DocumentationSetFile.LoadAndResolve( searchCtx.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), - new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - var searchNav = new DocumentationSetNavigation(searchDocset, searchCtx, GenericDocumentationFileFactory.Instance); + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + var searchNav = new DocumentationSetNavigation( + searchDocset, + searchCtx, + GenericDocumentationFileFactory.Instance + ); var siteCtx = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); var navigation = new SiteNavigation(siteNavFile, siteCtx, [obsNav, searchNav], sitePrefix: "/docs"); @@ -56,7 +64,8 @@ private static (SiteNavigation, DocumentationSetNavigation, public void SectionWithChildren_CreatesSectionNavigationNode() { // language=yaml - var yaml = """ + var yaml = + """ toc: - section: Guides children: @@ -70,8 +79,7 @@ public void SectionWithChildren_CreatesSectionNavigationNode() // Top-level should have exactly one item: the SectionNavigation nav.NavigationItems.Should().HaveCount(1); - var section = nav.NavigationItems.First() - .Should().BeOfType().Subject; + var section = nav.NavigationItems.First().Should().BeOfType().Subject; section.Title.Should().Be("Guides"); section.NavigationItems.Should().HaveCount(2); @@ -81,7 +89,8 @@ public void SectionWithChildren_CreatesSectionNavigationNode() public void SectionNavigationNode_IsIsland_AndParentIsSiteNavigation() { // language=yaml - var yaml = """ + var yaml = + """ toc: - section: Guides children: @@ -93,8 +102,7 @@ public void SectionNavigationNode_IsIsland_AndParentIsSiteNavigation() var (nav, _, _) = BuildTwoChildSection(output, yaml); - var section = nav.NavigationItems.First() - .Should().BeOfType().Subject; + var section = nav.NavigationItems.First().Should().BeOfType().Subject; section.IsIsland.Should().BeTrue(); section.Parent.Should().BeSameAs(nav, "SectionNavigation parent must be SiteNavigation"); @@ -105,7 +113,8 @@ public void SectionNavigationNode_IsIsland_AndParentIsSiteNavigation() public void SectionChildren_AreNotIslands_SectionIsTheIsland() { // language=yaml - var yaml = """ + var yaml = + """ toc: - section: Guides children: @@ -136,7 +145,8 @@ public void SectionChildren_AreNotIslands_SectionIsTheIsland() public void FindIslandRoot_FromDeepPage_ReturnsSectionNavigation() { // language=yaml - var yaml = """ + var yaml = + """ toc: - section: Guides children: @@ -151,14 +161,14 @@ public void FindIslandRoot_FromDeepPage_ReturnsSectionNavigation() var section = nav.NavigationItems.First().Should().BeOfType().Subject; // Pick a deep leaf inside the observability docset via NavigationIndexedByOrder - var deepLeaf = nav.NavigationIndexedByOrder.Values + var deepLeaf = nav.NavigationIndexedByOrder + .Values .OfType>() .FirstOrDefault(l => l.Url.Contains("monitoring")); deepLeaf.Should().NotBeNull("fixture has monitoring/ pages"); var islandRoot = deepLeaf.FindIslandRoot(); - islandRoot.Should().BeSameAs(section, - "FindIslandRoot walks past child docsets (not islands) and stops at the section island"); + islandRoot.Should().BeSameAs(section, "FindIslandRoot walks past child docsets (not islands) and stops at the section island"); } // ────────────────────────────────────────────────────────────── @@ -169,7 +179,8 @@ public void FindIslandRoot_FromDeepPage_ReturnsSectionNavigation() public void BackLink_FromSectionIsland_IncludesElasticDocs() { // language=yaml - var yaml = """ + var yaml = + """ toc: - section: Guides children: @@ -189,13 +200,12 @@ public void BackLink_FromSectionIsland_IncludesElasticDocs() topLevelItems: nav.TopLevelItems, isUsingNavigationDropdown: false, isPrimaryNavEnabled: true, - isGlobalAssemblyBuild: true); + isGlobalAssemblyBuild: true + ); // Only ancestor above the section is SiteNavigation ("Elastic Docs") - renderModel.BackLinks.Should().Contain(link => link.Title == "Elastic Docs", - "the section's only ancestor is SiteNavigation"); - renderModel.BackLinks.Should().NotContain(link => link.Title == "Guides", - "the section itself is not its own back-link"); + renderModel.BackLinks.Should().Contain(link => link.Title == "Elastic Docs", "the section's only ancestor is SiteNavigation"); + renderModel.BackLinks.Should().NotContain(link => link.Title == "Guides", "the section itself is not its own back-link"); } // ────────────────────────────────────────────────────────────── @@ -206,7 +216,8 @@ public void BackLink_FromSectionIsland_IncludesElasticDocs() public void ChildPageUrls_AreUnchanged_BySectionParent() { // language=yaml - var flat = """ + var flat = + """ toc: - toc: observability:// path_prefix: /observability @@ -215,7 +226,8 @@ public void ChildPageUrls_AreUnchanged_BySectionParent() """; // language=yaml - var sectioned = """ + var sectioned = + """ toc: - section: Guides children: @@ -234,22 +246,27 @@ string[] GetLeafUrls(string siteNavYaml) var obsDocset = DocumentationSetFile.LoadAndResolve( obsCtx.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), - new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); var obsNav = new DocumentationSetNavigation(obsDocset, obsCtx, GenericDocumentationFileFactory.Instance); var searchCtx = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/serverless-search", output); var searchDocset = DocumentationSetFile.LoadAndResolve( searchCtx.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), - new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - var searchNav = new DocumentationSetNavigation(searchDocset, searchCtx, GenericDocumentationFileFactory.Instance); + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + var searchNav = new DocumentationSetNavigation( + searchDocset, + searchCtx, + GenericDocumentationFileFactory.Instance + ); var siteCtx = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); var siteNav = new SiteNavigation(navFile, siteCtx, [obsNav, searchNav], sitePrefix: "/docs"); - return [..siteNav.NavigationIndexedByOrder.Values - .OfType>() - .Select(l => l.Url) - .Order()]; + return [ + .. siteNav.NavigationIndexedByOrder.Values.OfType>().Select(l => l.Url).Order() + ]; } var flatUrls = GetLeafUrls(flat); @@ -257,8 +274,7 @@ string[] GetLeafUrls(string siteNavYaml) // The set of leaf URLs must be identical regardless of whether entries // are nested under a section or flat at the top level. - sectionedUrls.Should().BeEquivalentTo(flatUrls, - "grouping toc entries under a section must not change any page URL"); + sectionedUrls.Should().BeEquivalentTo(flatUrls, "grouping toc entries under a section must not change any page URL"); } // ────────────────────────────────────────────────────────────── @@ -269,7 +285,8 @@ string[] GetLeafUrls(string siteNavYaml) public void SectionTopNavBuilder_BuildsTab_WithSectionId() { // language=yaml - var yaml = """ + var yaml = + """ toc: - section: Guides children: @@ -292,8 +309,7 @@ public void SectionTopNavBuilder_BuildsTab_WithSectionId() var tab = renderModel.Items[0].Should().BeOfType().Subject; tab.Title.Should().Be("Guides"); // All section pages have NavigationRoot = sectionNav, so a single SectionId suffices - tab.SectionId.Should().Be(section.Id, - "active-tab detection matches NavigationRoot.Id == section.Id"); + tab.SectionId.Should().Be(section.Id, "active-tab detection matches NavigationRoot.Id == section.Id"); tab.SectionIds.Should().BeNull("multi-root SectionIds are not needed when the section is the island"); } } diff --git a/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs b/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs index 2861cdc71c..4f16d17c93 100644 --- a/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs +++ b/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs @@ -25,14 +25,9 @@ public void CreatesDocumentationSetNavigationsFromCheckoutFolders() var repositories = checkoutDir.GetDirectories(); repositories.Should().HaveCount(5); - repositories.Select(r => r.Name).Should().Contain( - [ - "observability", - "serverless-search", - "serverless-security", - "platform", - "elasticsearch-reference" - ]); + repositories.Select(r => r.Name) + .Should() + .Contain(["observability", "serverless-search", "serverless-security", "platform", "elasticsearch-reference"]); var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); @@ -48,7 +43,11 @@ public void CreatesDocumentationSetNavigationsFromCheckoutFolders() ? $"{repo.FullName}/docs/docset.yml" : $"{repo.FullName}/docs/_docset.yml"; - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); + var docset = DocumentationSetFile.LoadAndResolve( + context.Collector, + fileSystem.FileInfo.New(docsetPath), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); documentationSets.Add(navigation); @@ -68,7 +67,8 @@ public void CreatesDocumentationSetNavigationsFromCheckoutFolders() public void SiteNavigationIntegratesWithDocumentationSets() { // language=yaml - var siteNavYaml = """ + var siteNavYaml = + """ toc: - toc: observability:// path_prefix: /serverless/observability @@ -85,16 +85,38 @@ public void SiteNavigationIntegratesWithDocumentationSets() var documentationSets = new List(); var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - documentationSets.Add(new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance)); + var observabilityDocset = DocumentationSetFile.LoadAndResolve( + observabilityContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + documentationSets.Add( + new DocumentationSetNavigation( + observabilityDocset, + observabilityContext, + GenericDocumentationFileFactory.Instance + ) + ); var searchContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-search", output); - var searchDocset = DocumentationSetFile.LoadAndResolve(searchContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - documentationSets.Add(new DocumentationSetNavigation(searchDocset, searchContext, GenericDocumentationFileFactory.Instance)); + var searchDocset = DocumentationSetFile.LoadAndResolve( + searchContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + documentationSets.Add( + new DocumentationSetNavigation(searchDocset, searchContext, GenericDocumentationFileFactory.Instance) + ); var securityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-security", output); - var securityDocset = DocumentationSetFile.LoadAndResolve(securityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-security/docs/_docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - documentationSets.Add(new DocumentationSetNavigation(securityDocset, securityContext, GenericDocumentationFileFactory.Instance)); + var securityDocset = DocumentationSetFile.LoadAndResolve( + securityContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/serverless-security/docs/_docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + documentationSets.Add( + new DocumentationSetNavigation(securityDocset, securityContext, GenericDocumentationFileFactory.Instance) + ); // Create site navigation context (using any repository's filesystem) var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); @@ -119,7 +141,8 @@ public void SiteNavigationIntegratesWithDocumentationSets() public void SiteNavigationWithNestedTocs() { // language=yaml - var siteNavYaml = """ + var siteNavYaml = + """ toc: - toc: platform:// path_prefix: /platform @@ -135,8 +158,16 @@ public void SiteNavigationWithNestedTocs() // Create DocumentationSetNavigation for platform var platformContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); - var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); + var platformDocset = DocumentationSetFile.LoadAndResolve( + platformContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + var platformNav = new DocumentationSetNavigation( + platformDocset, + platformContext, + GenericDocumentationFileFactory.Instance + ); platformNav.Url.Should().Be("/"); platformNav.Index.Url.Should().Be("/"); platformNav.NavigationItems.ElementAt(0).Url.Should().Be("/deployment-guide"); @@ -166,7 +197,8 @@ public void SiteNavigationWithNestedTocs() public void SiteNavigationWithAllRepositories() { // language=yaml - var siteNavYaml = """ + var siteNavYaml = + """ toc: - toc: observability:// path_prefix: /serverless/observability @@ -202,14 +234,17 @@ public void SiteNavigationWithAllRepositories() ? $"{repo.FullName}/docs/docset.yml" : $"{repo.FullName}/docs/_docset.yml"; - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); + var docset = DocumentationSetFile.LoadAndResolve( + context.Collector, + fileSystem.FileInfo.New(docsetPath), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); documentationSets.Add(navigation); } - var siteContext = SiteNavigationTestFixture.CreateContext( - fileSystem, "/checkouts/current/observability", output); + var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix: null); @@ -242,8 +277,16 @@ public void DocumentationSetNavigationHasCorrectStructure() // Test observability repository structure var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); + var observabilityDocset = DocumentationSetFile.LoadAndResolve( + observabilityContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + var observabilityNav = new DocumentationSetNavigation( + observabilityDocset, + observabilityContext, + GenericDocumentationFileFactory.Instance + ); observabilityNav.NavigationTitle.Should().Be(observabilityNav.NavigationTitle); observabilityNav.NavigationItems.Should().HaveCount(2); // index.md, getting-started folder, monitoring folder @@ -271,8 +314,16 @@ public void DocumentationSetWithNestedTocs() // Test platform repository with nested TOCs var platformContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); - var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); + var platformDocset = DocumentationSetFile.LoadAndResolve( + platformContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + var platformNav = new DocumentationSetNavigation( + platformDocset, + platformContext, + GenericDocumentationFileFactory.Instance + ); platformNav.NavigationTitle.Should().Be("Platform"); platformNav.NavigationItems.Should().HaveCount(2); // deployment-guide TOC, cloud-guide TOC @@ -301,8 +352,16 @@ public void DocumentationSetWithUnderscoreDocset() // Test serverless-security repository with _docset.yml var securityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-security", output); - var securityDocset = DocumentationSetFile.LoadAndResolve(securityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-security/docs/_docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - var securityNav = new DocumentationSetNavigation(securityDocset, securityContext, GenericDocumentationFileFactory.Instance); + var securityDocset = DocumentationSetFile.LoadAndResolve( + securityContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/serverless-security/docs/_docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + var securityNav = new DocumentationSetNavigation( + securityDocset, + securityContext, + GenericDocumentationFileFactory.Instance + ); securityNav.NavigationTitle.Should().Be("Serverless Security"); securityNav.NavigationItems.Should().HaveCount(2); // authentication folder, authorization folder @@ -322,7 +381,8 @@ public void DocumentationSetWithUnderscoreDocset() public void SiteNavigationAppliesPathPrefixToAllUrls() { // language=yaml - var siteNavYaml = """ + var siteNavYaml = + """ toc: - toc: observability:// path_prefix: /serverless/observability @@ -332,8 +392,19 @@ public void SiteNavigationAppliesPathPrefixToAllUrls() var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - var documentationSets = new List { new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance) }; + var observabilityDocset = DocumentationSetFile.LoadAndResolve( + observabilityContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + var documentationSets = new List + { + new DocumentationSetNavigation( + observabilityDocset, + observabilityContext, + GenericDocumentationFileFactory.Instance + ) + }; var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix: null); @@ -354,7 +425,8 @@ public void SiteNavigationAppliesPathPrefixToAllUrls() public void SiteNavigationWithNestedTocsAppliesCorrectPathPrefixes() { // language=yaml - var siteNavYaml = """ + var siteNavYaml = + """ toc: - toc: platform:// path_prefix: /platform @@ -369,8 +441,15 @@ public void SiteNavigationWithNestedTocsAppliesCorrectPathPrefixes() var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var platformContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); - var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - var documentationSets = new List { new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance) }; + var platformDocset = DocumentationSetFile.LoadAndResolve( + platformContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + var documentationSets = new List + { + new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance) + }; var siteContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix: null); @@ -400,8 +479,19 @@ public void SiteNavigationRequiresPathPrefix() var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - var documentationSets = new List { new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance) }; + var observabilityDocset = DocumentationSetFile.LoadAndResolve( + observabilityContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + var documentationSets = new List + { + new DocumentationSetNavigation( + observabilityDocset, + observabilityContext, + GenericDocumentationFileFactory.Instance + ) + }; var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix: null); @@ -423,7 +513,11 @@ public void ObservabilityDocumentationSetNavigationHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); + var docset = DocumentationSetFile.LoadAndResolve( + context.Collector, + docsetPath, + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -443,7 +537,11 @@ public void ServerlessSearchDocumentationSetNavigationHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-search", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); + var docset = DocumentationSetFile.LoadAndResolve( + context.Collector, + docsetPath, + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -463,7 +561,11 @@ public void ServerlessSecurityDocumentationSetNavigationHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-security", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/serverless-security/docs/_docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); + var docset = DocumentationSetFile.LoadAndResolve( + context.Collector, + docsetPath, + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -483,7 +585,11 @@ public void PlatformDocumentationSetNavigationHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); + var docset = DocumentationSetFile.LoadAndResolve( + context.Collector, + docsetPath, + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -503,7 +609,11 @@ public void ElasticsearchReferenceDocumentationSetNavigationHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/elasticsearch-reference", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/elasticsearch-reference/docs/docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); + var docset = DocumentationSetFile.LoadAndResolve( + context.Collector, + docsetPath, + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -534,7 +644,11 @@ public void AllDocumentationSetsHaveNoDiagnostics() ? $"{repo.FullName}/docs/docset.yml" : $"{repo.FullName}/docs/_docset.yml"; - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); + var docset = DocumentationSetFile.LoadAndResolve( + context.Collector, + fileSystem.FileInfo.New(docsetPath), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -555,7 +669,11 @@ public void DocumentationSetNavigationWithNestedTocsHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); + var docset = DocumentationSetFile.LoadAndResolve( + context.Collector, + docsetPath, + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -581,7 +699,11 @@ public void DocumentationSetNavigationWithFoldersHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); + var docset = DocumentationSetFile.LoadAndResolve( + context.Collector, + docsetPath, + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); diff --git a/tests/Navigation.Tests/Assembler/SiteNavigationTestFixture.cs b/tests/Navigation.Tests/Assembler/SiteNavigationTestFixture.cs index 7a15d0e7a8..7c79ce2d03 100644 --- a/tests/Navigation.Tests/Assembler/SiteNavigationTestFixture.cs +++ b/tests/Navigation.Tests/Assembler/SiteNavigationTestFixture.cs @@ -38,7 +38,8 @@ private static void SetupServerlessObservabilityRepository(MockFileSystem fileSy // Add docset.yml // language=yaml - var docsetYaml = """ + var docsetYaml = + """ project: serverless-observability toc: - file: index.md @@ -73,7 +74,8 @@ private static void SetupServerlessSearchRepository(MockFileSystem fileSystem) // Add docset.yml // language=yaml - var docsetYaml = """ + var docsetYaml = + """ project: serverless-search toc: - file: index.md @@ -106,7 +108,8 @@ private static void SetupServerlessSecurityRepository(MockFileSystem fileSystem) // Add docset.yml with underscore prefix // language=yaml - var docsetYaml = """ + var docsetYaml = + """ project: serverless-security toc: - file: index.md @@ -139,7 +142,8 @@ private static void SetupPlatformRepository(MockFileSystem fileSystem) // Add docset.yml // language=yaml - var docsetYaml = """ + var docsetYaml = + """ project: platform toc: - file: index.md @@ -153,7 +157,8 @@ private static void SetupPlatformRepository(MockFileSystem fileSystem) var deploymentBaseDir = $"{baseDir}/docs/deployment-guide"; fileSystem.AddDirectory(deploymentBaseDir); // language=yaml - var deploymentTocYaml = """ + var deploymentTocYaml = + """ toc: - file: index.md - folder: self-managed @@ -170,7 +175,8 @@ private static void SetupPlatformRepository(MockFileSystem fileSystem) var cloudBaseDir = $"{baseDir}/docs/cloud-guide"; fileSystem.AddDirectory(cloudBaseDir); // language=yaml - var cloudTocYaml = """ + var cloudTocYaml = + """ toc: - file: index.md - folder: aws @@ -194,7 +200,8 @@ private static void SetupElasticsearchReferenceRepository(MockFileSystem fileSys // Add docset.yml // language=yaml - var docsetYaml = """ + var docsetYaml = + """ project: elasticsearch-reference toc: - file: index.md diff --git a/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs b/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs index b82724fffc..1148b0732d 100644 --- a/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs +++ b/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs @@ -30,7 +30,8 @@ private TestDocumentationSetContext CreateContext(MockFileSystem? fileSystem = n public void ConstructorCreatesSiteNavigation() { // language=yaml - var yaml = """ + var yaml = + """ toc: - toc: observability:// path_prefix: /serverless/observability @@ -43,12 +44,28 @@ public void ConstructorCreatesSiteNavigation() // Create DocumentationSetNavigation instances for the referenced repos var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); + var observabilityDocset = DocumentationSetFile.LoadAndResolve( + observabilityContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + var observabilityNav = new DocumentationSetNavigation( + observabilityDocset, + observabilityContext, + GenericDocumentationFileFactory.Instance + ); var searchContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-search", output); - var searchDocset = DocumentationSetFile.LoadAndResolve(searchContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - var searchNav = new DocumentationSetNavigation(searchDocset, searchContext, GenericDocumentationFileFactory.Instance); + var searchDocset = DocumentationSetFile.LoadAndResolve( + searchContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + var searchNav = new DocumentationSetNavigation( + searchDocset, + searchContext, + GenericDocumentationFileFactory.Instance + ); var documentationSets = new List { observabilityNav, searchNav }; @@ -65,7 +82,8 @@ public void ConstructorCreatesSiteNavigation() public void SiteNavigationWithNestedChildren() { // language=yaml - var yaml = """ + var yaml = + """ toc: - toc: platform:// path_prefix: /platform @@ -81,8 +99,16 @@ public void SiteNavigationWithNestedChildren() // Create DocumentationSetNavigation for platform var platformContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); - var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); + var platformDocset = DocumentationSetFile.LoadAndResolve( + platformContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + var platformNav = new DocumentationSetNavigation( + platformDocset, + platformContext, + GenericDocumentationFileFactory.Instance + ); var documentationSets = new List { platformNav }; @@ -109,7 +135,8 @@ public void SiteNavigationWithNestedChildren() public void SitePrefixNormalizesSlashes(string? sitePrefix, string expectedRootUrl) { // language=yaml - var yaml = """ + var yaml = + """ toc: - toc: observability:// path_prefix: observability @@ -119,8 +146,16 @@ public void SitePrefixNormalizesSlashes(string? sitePrefix, string expectedRootU var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); + var observabilityDocset = DocumentationSetFile.LoadAndResolve( + observabilityContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + var observabilityNav = new DocumentationSetNavigation( + observabilityDocset, + observabilityContext, + GenericDocumentationFileFactory.Instance + ); var documentationSets = new List { observabilityNav }; @@ -145,7 +180,8 @@ public void SitePrefixNormalizesSlashes(string? sitePrefix, string expectedRootU public void SitePrefixAppliedToNavigationItemUrls(string? sitePrefix, string expectedObservabilityUrl) { // language=yaml - var yaml = """ + var yaml = + """ toc: - toc: observability:// path_prefix: observability @@ -155,8 +191,16 @@ public void SitePrefixAppliedToNavigationItemUrls(string? sitePrefix, string exp var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); + var observabilityDocset = DocumentationSetFile.LoadAndResolve( + observabilityContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + var observabilityNav = new DocumentationSetNavigation( + observabilityDocset, + observabilityContext, + GenericDocumentationFileFactory.Instance + ); var documentationSets = new List { observabilityNav }; @@ -165,7 +209,9 @@ public void SitePrefixAppliedToNavigationItemUrls(string? sitePrefix, string exp navigation.NavigationItems.Should().HaveCount(1); var observabilityItem = navigation.NavigationItems.First(); - observabilityItem.Url.Should().Be(expectedObservabilityUrl, $"sitePrefix '{sitePrefix}' should result in URL '{expectedObservabilityUrl}'"); + observabilityItem.Url + .Should() + .Be(expectedObservabilityUrl, $"sitePrefix '{sitePrefix}' should result in URL '{expectedObservabilityUrl}'"); } [Fact] @@ -181,7 +227,8 @@ public void NavigationNodeIdsAreUniqueAcrossDocsets() // Docset 1: product-a with getting-started folder var productADir = "/checkouts/current/product-a"; // language=yaml - var productADocset = """ + var productADocset = + """ project: product-a toc: - file: index.md @@ -198,7 +245,8 @@ public void NavigationNodeIdsAreUniqueAcrossDocsets() // Docset 2: product-b with getting-started folder (same relative path!) var productBDir = "/checkouts/current/product-b"; // language=yaml - var productBDocset = """ + var productBDocset = + """ project: product-b toc: - file: index.md @@ -214,18 +262,32 @@ public void NavigationNodeIdsAreUniqueAcrossDocsets() // Create navigation for both docsets var productAContext = SiteNavigationTestFixture.CreateContext(fileSystem, productADir, output); - var productADocsetFile = DocumentationSetFile.LoadAndResolve(productAContext.Collector, fileSystem.FileInfo.New($"{productADir}/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - var productANav = new DocumentationSetNavigation(productADocsetFile, productAContext, GenericDocumentationFileFactory.Instance); + var productADocsetFile = DocumentationSetFile.LoadAndResolve( + productAContext.Collector, + fileSystem.FileInfo.New($"{productADir}/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + var productANav = new DocumentationSetNavigation( + productADocsetFile, + productAContext, + GenericDocumentationFileFactory.Instance + ); var productBContext = SiteNavigationTestFixture.CreateContext(fileSystem, productBDir, output); - var productBDocsetFile = DocumentationSetFile.LoadAndResolve(productBContext.Collector, fileSystem.FileInfo.New($"{productBDir}/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - var productBNav = new DocumentationSetNavigation(productBDocsetFile, productBContext, GenericDocumentationFileFactory.Instance); + var productBDocsetFile = DocumentationSetFile.LoadAndResolve( + productBContext.Collector, + fileSystem.FileInfo.New($"{productBDir}/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + var productBNav = new DocumentationSetNavigation( + productBDocsetFile, + productBContext, + GenericDocumentationFileFactory.Instance + ); // Get the "getting-started" folders from each docset - var productAGettingStarted = productANav.NavigationItems.First() - .Should().BeOfType>().Subject; - var productBGettingStarted = productBNav.NavigationItems.First() - .Should().BeOfType>().Subject; + var productAGettingStarted = productANav.NavigationItems.First().Should().BeOfType>().Subject; + var productBGettingStarted = productBNav.NavigationItems.First().Should().BeOfType>().Subject; // Both folders have the same relative path within their docsets productAGettingStarted.FolderPath.Should().Be("getting-started"); @@ -234,13 +296,18 @@ public void NavigationNodeIdsAreUniqueAcrossDocsets() // But they MUST have different IDs because they resolve to different URLs // Product A: /product-a/getting-started // Product B: /product-b/getting-started - productAGettingStarted.Id.Should().NotBe(productBGettingStarted.Id, - "folders with the same relative path but in different docsets should have different IDs " + - "because they have different URLs"); + productAGettingStarted.Id + .Should() + .NotBe( + productBGettingStarted.Id, + "folders with the same relative path but in different docsets should have different IDs " + + "because they have different URLs" + ); // Also verify in assembled navigation context // language=yaml - var siteNavYaml = """ + var siteNavYaml = + """ toc: - toc: product-a:// path_prefix: /product-a @@ -253,16 +320,19 @@ public void NavigationNodeIdsAreUniqueAcrossDocsets() var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix: null); // Use production YieldAll() to collect all navigation items - var allNodeItems = ((INavigationTraversable)siteNavigation).YieldAll() + var allNodeItems = ((INavigationTraversable)siteNavigation) + .YieldAll() .OfType>() .ToList(); // Verify all IDs are unique var allIds = allNodeItems.Select(n => n.Id).ToList(); var uniqueIds = allIds.Distinct().ToList(); - uniqueIds.Should().HaveCount(allIds.Count, + uniqueIds.Should().HaveCount( + allIds.Count, $"all navigation node IDs should be unique in assembled navigation. " + - $"Found duplicates: {string.Join(", ", allIds.GroupBy(id => id).Where(g => g.Count() > 1).Select(g => $"ID '{g.Key}' appears {g.Count()} times"))}"); + $"Found duplicates: {string.Join(", ", allIds.GroupBy(id => id).Where(g => g.Count() > 1).Select(g => $"ID '{g.Key}' appears {g.Count()} times"))}" + ); } [Theory] @@ -274,7 +344,8 @@ public void NavigationNodeIdsAreUniqueAcrossDocsets() public void SitePrefixAppliedToMultipleNavigationItems(string? sitePrefix, string expectedObsUrl, string expectedSearchUrl) { // language=yaml - var yaml = """ + var yaml = + """ toc: - toc: observability:// path_prefix: observability @@ -286,12 +357,28 @@ public void SitePrefixAppliedToMultipleNavigationItems(string? sitePrefix, strin var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); + var observabilityDocset = DocumentationSetFile.LoadAndResolve( + observabilityContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + var observabilityNav = new DocumentationSetNavigation( + observabilityDocset, + observabilityContext, + GenericDocumentationFileFactory.Instance + ); var searchContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-search", output); - var searchDocset = DocumentationSetFile.LoadAndResolve(searchContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); - var searchNav = new DocumentationSetNavigation(searchDocset, searchContext, GenericDocumentationFileFactory.Instance); + var searchDocset = DocumentationSetFile.LoadAndResolve( + searchContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem) + ); + var searchNav = new DocumentationSetNavigation( + searchDocset, + searchContext, + GenericDocumentationFileFactory.Instance + ); var documentationSets = new List { observabilityNav, searchNav }; diff --git a/tests/Navigation.Tests/Codex/CodexConfigurationLoaderTests.cs b/tests/Navigation.Tests/Codex/CodexConfigurationLoaderTests.cs index 7049993ca9..3df1e1de6e 100644 --- a/tests/Navigation.Tests/Codex/CodexConfigurationLoaderTests.cs +++ b/tests/Navigation.Tests/Codex/CodexConfigurationLoaderTests.cs @@ -18,11 +18,9 @@ public class CodexConfigurationLoaderTests(ITestOutputHelper output) groups: [] """; - private static readonly string ConfigPath = - Path.Join(Paths.WorkingDirectoryRoot.FullName, "codex.yml"); + private static readonly string ConfigPath = Path.Join(Paths.WorkingDirectoryRoot.FullName, "codex.yml"); - private CheckoutsFileSystem ScopedFs(MockFileSystem mockFs) => - CheckoutsFileSystem.FromWorkingDirectory(mockFs); + private CheckoutsFileSystem ScopedFs(MockFileSystem mockFs) => CheckoutsFileSystem.FromWorkingDirectory(mockFs); private TestDiagnosticsCollector Collector() => new(output); @@ -61,10 +59,7 @@ public void TryLoad_MissingEnvironmentField_ReturnsFalseWithError() [Fact] public void TryLoad_ValidConfig_ReturnsTrueAndEnvironment() { - var mockFs = new MockFileSystem(new Dictionary - { - { ConfigPath, new MockFileData(ValidConfig) } - }); + var mockFs = new MockFileSystem(new Dictionary { { ConfigPath, new MockFileData(ValidConfig) } }); var fs = ScopedFs(mockFs); var configFile = fs.FileInfo.New(ConfigPath); var collector = Collector(); @@ -102,10 +97,7 @@ public void TryLoad_ScopedFileSystemOutOfScope_ReturnsFalseWithError() // ScopedFileInfo.Exists returns true (unguarded), but OpenText throws. // TryLoad must catch that and emit a visible error instead of propagating. var outsidePath = Path.Join(Path.GetTempPath(), "codex.yml"); - var mockFs = new MockFileSystem(new Dictionary - { - { outsidePath, new MockFileData(ValidConfig) } - }); + var mockFs = new MockFileSystem(new Dictionary { { outsidePath, new MockFileData(ValidConfig) } }); // Scope the FS only to the working dir — the outsidePath is outside it var fs = ScopedFs(mockFs); var configFile = fs.FileInfo.New(outsidePath); diff --git a/tests/Navigation.Tests/Codex/CodexNavigationRenderingTests.cs b/tests/Navigation.Tests/Codex/CodexNavigationRenderingTests.cs index 065689032c..a6b880b65b 100644 --- a/tests/Navigation.Tests/Codex/CodexNavigationRenderingTests.cs +++ b/tests/Navigation.Tests/Codex/CodexNavigationRenderingTests.cs @@ -23,10 +23,8 @@ public class CodexNavigationRenderingTests(ITestOutputHelper output) : CodexNavi public async Task ProjectlessRepositories_DifferentTrees_ProduceDifferentContentHashes() { var docSetNavigations = CreateMockDocSetNavigations(["codex-environments", "ml-team"], includeProject: false); - var first = docSetNavigations["codex-environments"] - .Should().BeAssignableTo>().Subject; - var second = docSetNavigations["ml-team"] - .Should().BeAssignableTo>().Subject; + var first = docSetNavigations["codex-environments"].Should().BeAssignableTo>().Subject; + var second = docSetNavigations["ml-team"].Should().BeAssignableTo>().Subject; first.Id.Should().Be(second.Id); @@ -42,7 +40,8 @@ public async Task ProjectlessRepositories_DifferentTrees_ProduceDifferentContent public void GroupNavigation_TopLevelItems_ContainsAllGroupMembers() { // Arrange: Create a codex with grouped repos - CodexDocumentationSetReference[] docSets = [ + CodexDocumentationSetReference[] docSets = + [ new CodexDocumentationSetReference { Name = "apm-agent", Branch = "main", Group = "observability" }, new CodexDocumentationSetReference { Name = "uptime", Branch = "main", Group = "observability" }, new CodexDocumentationSetReference { Name = "logs", Branch = "main", Group = "observability" } @@ -56,18 +55,15 @@ public void GroupNavigation_TopLevelItems_ContainsAllGroupMembers() // Assert: Group navigation's top-level items should contain all 3 repos groupNav.NavigationItems.Should().HaveCount(3); - groupNav.NavigationItems.Select(i => i.Url).Should().BeEquivalentTo([ - "/docs/r/apm-agent", - "/docs/r/uptime", - "/docs/r/logs" - ]); + groupNav.NavigationItems.Select(i => i.Url).Should().BeEquivalentTo(["/docs/r/apm-agent", "/docs/r/uptime", "/docs/r/logs"]); } [Fact] public void GroupNavigation_TopLevelItems_UseIndexH1() { // Arrange: Mock creates index.md with "# {repoName}" so h1 is "apm-agent" and "uptime" - CodexDocumentationSetReference[] docSets = [ + CodexDocumentationSetReference[] docSets = + [ new CodexDocumentationSetReference { Name = "apm-agent", Branch = "main", Group = "observability" }, new CodexDocumentationSetReference { Name = "uptime", Branch = "main", Group = "observability" } ]; @@ -78,10 +74,7 @@ public void GroupNavigation_TopLevelItems_UseIndexH1() var groupNav = codexNav.GroupNavigations.First(); // Assert: Navigation titles use index.md h1, not display_name - groupNav.NavigationItems.Select(i => i.NavigationTitle).Should().BeEquivalentTo([ - "apm-agent", - "uptime" - ]); + groupNav.NavigationItems.Select(i => i.NavigationTitle).Should().BeEquivalentTo(["apm-agent", "uptime"]); } [Fact] @@ -112,7 +105,8 @@ public void UngroupedRepo_NavigationRoot_IsItself() public void GroupedRepo_NavigationRoot_IsGroupNavigation() { // Arrange - CodexDocumentationSetReference[] docSets = [ + CodexDocumentationSetReference[] docSets = + [ new CodexDocumentationSetReference { Name = "apm", Branch = "main", Group = "observability" }, new CodexDocumentationSetReference { Name = "uptime", Branch = "main", Group = "observability" } ]; @@ -140,7 +134,8 @@ public void GroupedRepo_NavigationRoot_IsGroupNavigation() public void CodexNavigation_TopLevelItems_ShowsGroupLinksAndUngroupedRepos() { // Arrange: Mix of grouped and ungrouped repos - CodexDocumentationSetReference[] docSets = [ + CodexDocumentationSetReference[] docSets = + [ new CodexDocumentationSetReference { Name = "apm", Branch = "main", Group = "observability" }, new CodexDocumentationSetReference { Name = "uptime", Branch = "main", Group = "observability" }, new CodexDocumentationSetReference { Name = "standalone1", Branch = "main" }, @@ -169,7 +164,8 @@ public void CodexNavigation_TopLevelItems_ShowsGroupLinksAndUngroupedRepos() public void AllGroupMembers_ShareSameNavigationRoot() { // Arrange - CodexDocumentationSetReference[] docSets = [ + CodexDocumentationSetReference[] docSets = + [ new CodexDocumentationSetReference { Name = "repo1", Branch = "main", Group = "group1" }, new CodexDocumentationSetReference { Name = "repo2", Branch = "main", Group = "group1" }, new CodexDocumentationSetReference { Name = "repo3", Branch = "main", Group = "group1" } @@ -193,7 +189,8 @@ public void AllGroupMembers_ShareSameNavigationRoot() public void DifferentGroups_HaveDifferentNavigationRoots() { // Arrange - CodexDocumentationSetReference[] docSets = [ + CodexDocumentationSetReference[] docSets = + [ new CodexDocumentationSetReference { Name = "obs-repo", Branch = "main", Group = "observability" }, new CodexDocumentationSetReference { Name = "sec-repo", Branch = "main", Group = "security" } ]; @@ -215,7 +212,8 @@ public void DifferentGroups_HaveDifferentNavigationRoots() public void GroupLandingPage_HasAllMembersAsNavigationItems() { // Arrange - CodexDocumentationSetReference[] docSets = [ + CodexDocumentationSetReference[] docSets = + [ new CodexDocumentationSetReference { Name = "a", Branch = "main", Group = "tools" }, new CodexDocumentationSetReference { Name = "b", Branch = "main", Group = "tools" }, new CodexDocumentationSetReference { Name = "c", Branch = "main", Group = "tools" } @@ -234,20 +232,16 @@ public void GroupLandingPage_HasAllMembersAsNavigationItems() groupNav.Index.NavigationTitle.Should().Be("Tools"); } - private static async Task RenderNavigation( - IRootNavigationItem navigation) + private static async Task RenderNavigation(IRootNavigationItem navigation) { var renderModel = NavigationRenderModel.Create( tree: navigation, topLevelItems: navigation.NavigationItems.OfType>(), isUsingNavigationDropdown: false, isPrimaryNavEnabled: false, - isGlobalAssemblyBuild: false); + isGlobalAssemblyBuild: false + ); var html = await _TocTree.Create(renderModel).RenderAsync(cancellationToken: TestContext.Current.CancellationToken); - return new NavigationRenderResult - { - Html = html, - Id = renderModel.ContentHash - }; + return new NavigationRenderResult { Html = html, Id = renderModel.ContentHash }; } } diff --git a/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs b/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs index cc5bbef219..35f78648da 100644 --- a/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs +++ b/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs @@ -22,15 +22,12 @@ public abstract class CodexNavigationTestBase(ITestOutputHelper output) protected ICodexDocumentationContext CreateContext() => new TestCodexDocumentationContext(Collector); protected static CodexConfiguration CreateCodexConfiguration(string sitePrefix) => - new() - { - Title = "Test Codex", - SitePrefix = sitePrefix - }; + new() { Title = "Test Codex", SitePrefix = sitePrefix }; protected IReadOnlyDictionary CreateMockDocSetNavigations( IEnumerable repoNames, - bool includeProject = true) + bool includeProject = true + ) { var result = new Dictionary(); var fileSystem = new MockFileSystem(); @@ -44,10 +41,14 @@ protected IReadOnlyDictionary CreateMockDoc fileSystem.DirectoryInfo.New($"/{repoName}/output"), fileSystem.FileInfo.New($"/{repoName}/docs/docset.yml"), output, - repoName); + repoName + ); var navigation = new DocumentationSetNavigation( - docSet, context, CodexTestDocumentationFileFactory.Instance); + docSet, + context, + CodexTestDocumentationFileFactory.Instance + ); result[repoName] = navigation; } @@ -62,14 +63,9 @@ private static DocumentationSetFile CreateMockDocumentationSet(MockFileSystem fi fileSystem.AddFile($"{docsPath}/index.md", new MockFileData($"# {repoName}")); // language=yaml - var yaml = includeProject - ? $"project: '{repoName}'\ntoc:\n - file: index.md" - : "toc:\n - file: index.md"; - - return DocumentationSetFile.LoadAndResolve( - new DiagnosticsCollector([]), - yaml, - fileSystem.DirectoryInfo.New(docsPath)); + var yaml = includeProject ? $"project: '{repoName}'\ntoc:\n - file: index.md" : "toc:\n - file: index.md"; + + return DocumentationSetFile.LoadAndResolve(new DiagnosticsCollector([]), yaml, fileSystem.DirectoryInfo.New(docsPath)); } } @@ -79,8 +75,10 @@ internal sealed class TestCodexDocumentationContext(IDiagnosticsCollector collec public IFileInfo ConfigurationPath => _fileSystem.FileInfo.New(_fileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, "codex.yml")); public IDiagnosticsCollector Collector => collector; - public DocumentationWriteFileSystem WriteFileSystem => new(_fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, _fileSystem); - public IDirectoryInfo OutputDirectory => _fileSystem.DirectoryInfo.New(_fileSystem.Path.Join(Paths.ApplicationData.FullName, "codex", "output")); + public DocumentationWriteFileSystem WriteFileSystem => + new(_fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, _fileSystem); + public IDirectoryInfo OutputDirectory => + _fileSystem.DirectoryInfo.New(_fileSystem.Path.Join(Paths.ApplicationData.FullName, "codex", "output")); public BuildType BuildType => BuildType.Codex; public void EmitError(string message) => collector.EmitError(ConfigurationPath, message); diff --git a/tests/Navigation.Tests/Codex/CodexNavigationTests.cs b/tests/Navigation.Tests/Codex/CodexNavigationTests.cs index 3c521afc70..af340db4ba 100644 --- a/tests/Navigation.Tests/Codex/CodexNavigationTests.cs +++ b/tests/Navigation.Tests/Codex/CodexNavigationTests.cs @@ -15,7 +15,8 @@ public class CodexNavigationTests(ITestOutputHelper output) : CodexNavigationTes [Fact] public void UngroupedRepos_UseStableRUrls() { - CodexDocumentationSetReference[] docSets = [ + CodexDocumentationSetReference[] docSets = + [ new CodexDocumentationSetReference { Name = "repo-a", Branch = "main" }, new CodexDocumentationSetReference { Name = "repo-b", Branch = "main" } ]; @@ -30,7 +31,8 @@ public void UngroupedRepos_UseStableRUrls() [Fact] public void GroupedRepos_UseStableRUrls_NotGroupUrls() { - CodexDocumentationSetReference[] docSets = [ + CodexDocumentationSetReference[] docSets = + [ new CodexDocumentationSetReference { Name = "apm-agent", Branch = "main", Group = "observability" }, new CodexDocumentationSetReference { Name = "uptime", Branch = "main", Group = "observability" } ]; @@ -49,7 +51,8 @@ public void GroupedRepos_UseStableRUrls_NotGroupUrls() [Fact] public void GroupNavigation_ContainsAllGroupMembers() { - CodexDocumentationSetReference[] docSets = [ + CodexDocumentationSetReference[] docSets = + [ new CodexDocumentationSetReference { Name = "apm-agent", Branch = "main", Group = "observability" }, new CodexDocumentationSetReference { Name = "uptime", Branch = "main", Group = "observability" }, new CodexDocumentationSetReference { Name = "standalone", Branch = "main" } @@ -67,7 +70,10 @@ public void GroupNavigation_ContainsAllGroupMembers() [Fact] public void GroupedRepos_HaveGroupNavigationAsRoot() { - CodexDocumentationSetReference[] docSets = [new CodexDocumentationSetReference { Name = "apm-agent", Branch = "main", Group = "observability" }]; + CodexDocumentationSetReference[] docSets = + [ + new CodexDocumentationSetReference { Name = "apm-agent", Branch = "main", Group = "observability" } + ]; var config = CreateCodexConfiguration("/docs"); var docSetNavigations = CreateMockDocSetNavigations(["apm-agent"]); var codexNav = new CodexNavigation(config, docSets, CreateContext(), docSetNavigations); @@ -109,7 +115,8 @@ public void UngroupedRepos_HaveOwnNavigationAsRoot() [Fact] public void IndexH1_UsedAsDocsetTitle_DisplayNameIgnored() { - CodexDocumentationSetReference[] docSets = [ + CodexDocumentationSetReference[] docSets = + [ new CodexDocumentationSetReference { Name = "apm-agent", @@ -133,7 +140,8 @@ public void IndexH1_UsedAsDocsetTitle_DisplayNameIgnored() [Fact] public void EmptySitePrefix_GeneratesRootUrls() { - CodexDocumentationSetReference[] docSets = [ + CodexDocumentationSetReference[] docSets = + [ new CodexDocumentationSetReference { Name = "repo-a", Branch = "main" }, new CodexDocumentationSetReference { Name = "repo-b", Branch = "main", Group = "tools" } ]; @@ -169,7 +177,8 @@ public void SlashSitePrefix_TreatedAsRoot() [Fact] public void MultipleCategories_CreateSeparateGroupNavigations() { - CodexDocumentationSetReference[] docSets = [ + CodexDocumentationSetReference[] docSets = + [ new CodexDocumentationSetReference { Name = "apm", Branch = "main", Group = "observability" }, new CodexDocumentationSetReference { Name = "uptime", Branch = "main", Group = "observability" }, new CodexDocumentationSetReference { Name = "siem", Branch = "main", Group = "security" }, @@ -193,7 +202,10 @@ public void MultipleCategories_CreateSeparateGroupNavigations() [Fact] public void GroupTitle_FormatsSlugToTitleCase() { - CodexDocumentationSetReference[] docSets = [new CodexDocumentationSetReference { Name = "tool", Branch = "main", Group = "developer-tools" }]; + CodexDocumentationSetReference[] docSets = + [ + new CodexDocumentationSetReference { Name = "tool", Branch = "main", Group = "developer-tools" } + ]; var config = CreateCodexConfiguration("/docs"); var docSetNavigations = CreateMockDocSetNavigations(["tool"]); var codexNav = new CodexNavigation(config, docSets, CreateContext(), docSetNavigations); @@ -205,7 +217,8 @@ public void GroupTitle_FormatsSlugToTitleCase() [Fact] public void CodexNavigationItems_ContainGroupLinksAndUngroupedRepos() { - CodexDocumentationSetReference[] docSets = [ + CodexDocumentationSetReference[] docSets = + [ new CodexDocumentationSetReference { Name = "grouped1", Branch = "main", Group = "tools" }, new CodexDocumentationSetReference { Name = "grouped2", Branch = "main", Group = "tools" }, new CodexDocumentationSetReference { Name = "ungrouped", Branch = "main" } @@ -222,7 +235,11 @@ public void CodexNavigationItems_ContainGroupLinksAndUngroupedRepos() var groupLink = codexNav.NavigationItems.OfType().Should().ContainSingle().Subject; groupLink.Url.Should().Be("/docs/g/tools"); - var ungroupedNav = codexNav.NavigationItems.OfType>().Should().ContainSingle().Subject; + var ungroupedNav = codexNav.NavigationItems + .OfType>() + .Should() + .ContainSingle() + .Subject; ungroupedNav.Url.Should().Be("/docs/r/ungrouped"); } } diff --git a/tests/Navigation.Tests/Codex/FindDocsetFileTests.cs b/tests/Navigation.Tests/Codex/FindDocsetFileTests.cs index d936af460f..812d33f431 100644 --- a/tests/Navigation.Tests/Codex/FindDocsetFileTests.cs +++ b/tests/Navigation.Tests/Codex/FindDocsetFileTests.cs @@ -16,8 +16,7 @@ public class FindDocsetFileTests private static readonly string RepoRoot = Path.Join(Paths.WorkingDirectoryRoot.FullName, "repo"); - private static CheckoutsFileSystem CreateScopedFs(MockFileSystem mockFs) => - CheckoutsFileSystem.FromWorkingDirectory(mockFs); + private static CheckoutsFileSystem CreateScopedFs(MockFileSystem mockFs) => CheckoutsFileSystem.FromWorkingDirectory(mockFs); [Fact] public void StandardPath_Found() diff --git a/tests/Navigation.Tests/Codex/GroupNavigationTests.cs b/tests/Navigation.Tests/Codex/GroupNavigationTests.cs index 7a2de3d6b6..2c183382c7 100644 --- a/tests/Navigation.Tests/Codex/GroupNavigationTests.cs +++ b/tests/Navigation.Tests/Codex/GroupNavigationTests.cs @@ -124,20 +124,22 @@ public void GroupIndexLeaf_PropertiesSetCorrectly() private static CodexNavigation CreateMinimalCodexNavigation() { // Create a minimal codex navigation for testing GroupLinkLeaf - var config = new Documentation.Configuration.Codex.CodexConfiguration - { - Title = "Test Codex", - SitePrefix = "/docs" - }; - - return new CodexNavigation(config, [], new MinimalCodexContext(), new Dictionary()); + var config = new Documentation.Configuration.Codex.CodexConfiguration { Title = "Test Codex", SitePrefix = "/docs" }; + + return new CodexNavigation( + config, + [], + new MinimalCodexContext(), + new Dictionary() + ); } private sealed class MinimalCodexContext : ICodexDocumentationContext { private readonly System.IO.Abstractions.TestingHelpers.MockFileSystem _fs = new(); public System.IO.Abstractions.IFileInfo ConfigurationPath => _fs.FileInfo.New("/codex.yml"); - public Elastic.Documentation.Diagnostics.IDiagnosticsCollector Collector => new Elastic.Documentation.Diagnostics.DiagnosticsCollector([]); + public Elastic.Documentation.Diagnostics.IDiagnosticsCollector Collector => + new Elastic.Documentation.Diagnostics.DiagnosticsCollector([]); public DocumentationWriteFileSystem WriteFileSystem => new(_fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, _fs); public System.IO.Abstractions.IDirectoryInfo OutputDirectory => _fs.DirectoryInfo.New("/output"); public BuildType BuildType => BuildType.Codex; diff --git a/tests/Navigation.Tests/Isolation/ConstructorTests.cs b/tests/Navigation.Tests/Isolation/ConstructorTests.cs index 4b1fbde5b9..12b4300178 100644 --- a/tests/Navigation.Tests/Isolation/ConstructorTests.cs +++ b/tests/Navigation.Tests/Isolation/ConstructorTests.cs @@ -18,7 +18,8 @@ public class ConstructorTests(ITestOutputHelper output) : DocumentationSetNaviga public void ConstructorInitializesRootProperties() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: index.md @@ -44,7 +45,8 @@ public void ConstructorInitializesRootProperties() public void ConstructorSetsIsUsingNavigationDropdownFromFeatures() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' features: primary-nav: true @@ -66,7 +68,8 @@ public void ConstructorSetsIsUsingNavigationDropdownFromFeatures() public void ConstructorCreatesFileNavigationLeafFromFileRef() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: getting-started.md @@ -92,7 +95,8 @@ public void ConstructorCreatesFileNavigationLeafFromFileRef() public void ConstructorCreatesHiddenFileNavigationLeaf() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - hidden: 404.md @@ -115,7 +119,8 @@ public void ConstructorCreatesHiddenFileNavigationLeaf() public void ConstructorCreatesCrossLinkNavigation() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - title: "External Guide" @@ -127,7 +132,12 @@ public void ConstructorCreatesCrossLinkNavigation() var context = CreateContext(fileSystem); var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); - var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance, crossLinkResolver: TestCrossLinkResolver.Instance); + var navigation = new DocumentationSetNavigation( + docSet, + context, + GenericDocumentationFileFactory.Instance, + crossLinkResolver: TestCrossLinkResolver.Instance + ); navigation.NavigationItems.Should().HaveCount(0); var crossLink = navigation.Index.Should().BeOfType().Subject; @@ -139,7 +149,8 @@ public void ConstructorCreatesCrossLinkNavigation() public void ConstructorCreatesFolderNavigationWithChildren() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: setup @@ -208,14 +219,16 @@ public void ConstructorCreatesTableOfContentsNavigationWithChildren() public void ConstructorReadsTableOfContentsFromTocYmlFile() { // language=yaml - var docSetYaml = """ + var docSetYaml = + """ project: 'test-project' toc: - toc: api """; // language=yaml - var tocYaml = """ + var tocYaml = + """ toc: - file: overview.md - file: reference.md @@ -246,7 +259,8 @@ public void ConstructorReadsTableOfContentsFromTocYmlFile() public async Task ConstructorProcessesTocYmlItemsBeforeChildrenFromNavigation() { // language=yaml - var docSetYaml = """ + var docSetYaml = + """ project: 'test-project' toc: - toc: api @@ -280,9 +294,9 @@ public async Task ConstructorProcessesTocYmlItemsBeforeChildrenFromNavigation() // We expect 2 errors: one for the TOC validation error, and one from navigation constructor // After LoadAndResolve removes the invalid TOC item, the navigation sees an empty TOC context.Diagnostics.Should().HaveCount(2); - diagnostics.Should().Contain(d => - d.Message.Contains("TableOfContents 'api' may not contain children, define children in 'api/toc.yml' instead.")); - diagnostics.Should().Contain(d => - d.Message.Contains("has no table of contents defined")); + diagnostics.Should().Contain( + d => d.Message.Contains("TableOfContents 'api' may not contain children, define children in 'api/toc.yml' instead.") + ); + diagnostics.Should().Contain(d => d.Message.Contains("has no table of contents defined")); } } diff --git a/tests/Navigation.Tests/Isolation/DynamicUrlTests.cs b/tests/Navigation.Tests/Isolation/DynamicUrlTests.cs index 8399b5b636..fff9fc7c4a 100644 --- a/tests/Navigation.Tests/Isolation/DynamicUrlTests.cs +++ b/tests/Navigation.Tests/Isolation/DynamicUrlTests.cs @@ -18,7 +18,8 @@ public class DynamicUrlTests(ITestOutputHelper output) : DocumentationSetNavigat public void DynamicUrlUpdatesWhenRootUrlChanges() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: setup @@ -60,7 +61,8 @@ public void DynamicUrlUpdatesWhenRootUrlChanges() public void UrlRootPropagatesCorrectlyThroughFolders() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: outer @@ -92,7 +94,8 @@ public void UrlRootPropagatesCorrectlyThroughFolders() public void FolderWithoutIndexUsesFirstChildUrl() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: guides @@ -117,7 +120,8 @@ public void FolderWithoutIndexUsesFirstChildUrl() public void FolderWithoutIndexUsesFirstVisibleChildUrlWhenHiddenChildComesFirst() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: guides @@ -144,7 +148,8 @@ public void FolderWithoutIndexUsesFirstVisibleChildUrlWhenHiddenChildComesFirst( public void FolderWithoutIndexAndOnlyHiddenChildrenIsHidden() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: guides @@ -169,7 +174,8 @@ public void FolderWithoutIndexAndOnlyHiddenChildrenIsHidden() public void FolderWithNestedChildren() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: guides @@ -205,7 +211,8 @@ public void FolderWithNestedChildren() public void FolderWithNestedDeeplinkedChildren() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: guides @@ -241,7 +248,8 @@ public void FolderWithNestedDeeplinkedChildren() public void FolderWithNestedDeeplinkedOfIndexChildren() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: guides @@ -277,7 +285,8 @@ public void FolderWithNestedDeeplinkedOfIndexChildren() public void FolderWithIndexUsesOwnUrl() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: guides @@ -302,7 +311,8 @@ public void FolderWithIndexUsesOwnUrl() public void UrlRootChangesForTableOfContentsNavigation() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: guides diff --git a/tests/Navigation.Tests/Isolation/FileInfoValidationTests.cs b/tests/Navigation.Tests/Isolation/FileInfoValidationTests.cs index e9ee0d755e..2affaa8f43 100644 --- a/tests/Navigation.Tests/Isolation/FileInfoValidationTests.cs +++ b/tests/Navigation.Tests/Isolation/FileInfoValidationTests.cs @@ -22,7 +22,8 @@ public class FileInfoValidationTests(ITestOutputHelper output) : DocumentationSe public void AllFileNavigationItemsHaveValidFileInfoForSimpleFiles() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: getting-started.md @@ -30,11 +31,7 @@ public void AllFileNavigationItemsHaveValidFileInfoForSimpleFiles() - file: configuration.md """; - var fileSystem = CreateMockFileSystemWithFiles([ - "/docs/getting-started.md", - "/docs/installation.md", - "/docs/configuration.md" - ]); + var fileSystem = CreateMockFileSystemWithFiles(["/docs/getting-started.md", "/docs/installation.md", "/docs/configuration.md"]); var context = CreateContext(fileSystem); var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); @@ -58,7 +55,8 @@ public void AllFileNavigationItemsHaveValidFileInfoForSimpleFiles() public void AllFileNavigationItemsHaveValidFileInfoForVirtualFiles() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: guide.md @@ -102,7 +100,8 @@ public void AllFileNavigationItemsHaveValidFileInfoForVirtualFiles() public void AllFileNavigationItemsHaveValidFileInfoForFoldersWithFiles() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: setup @@ -150,7 +149,8 @@ public void AllFileNavigationItemsHaveValidFileInfoForFoldersWithFiles() public void AllFileNavigationItemsHaveValidFileInfoForDeeplyNestedTocFiles() { // language=yaml - var docsetYaml = """ + var docsetYaml = + """ project: 'test-project' toc: - file: index.md @@ -163,7 +163,8 @@ public void AllFileNavigationItemsHaveValidFileInfoForDeeplyNestedTocFiles() // Create a TOC file in a subdirectory // language=yaml - var developmentTocYaml = """ + var developmentTocYaml = + """ toc: - file: index.md - file: contributing.md @@ -174,7 +175,8 @@ public void AllFileNavigationItemsHaveValidFileInfoForDeeplyNestedTocFiles() // Create a deeply nested TOC file // language=yaml - var advancedTocYaml = """ + var advancedTocYaml = + """ toc: - file: index.md - file: patterns.md @@ -183,7 +185,8 @@ public void AllFileNavigationItemsHaveValidFileInfoForDeeplyNestedTocFiles() // Create a third-level nested TOC // language=yaml - var performanceTocYaml = """ + var performanceTocYaml = + """ toc: - file: index.md - file: optimization.md @@ -237,7 +240,8 @@ public void AllFileNavigationItemsHaveValidFileInfoForDeeplyNestedTocFiles() public void AllFileNavigationItemsHaveValidFileInfoForComplexMixedStructure() { // language=yaml - var docsetYaml = """ + var docsetYaml = + """ project: 'test-project' toc: - file: index.md @@ -261,7 +265,8 @@ public void AllFileNavigationItemsHaveValidFileInfoForComplexMixedStructure() // Setup/advanced TOC // language=yaml - var setupAdvancedTocYaml = """ + var setupAdvancedTocYaml = + """ toc: - file: index.md - file: custom-config.md @@ -269,7 +274,8 @@ public void AllFileNavigationItemsHaveValidFileInfoForComplexMixedStructure() // Reference TOC // language=yaml - var referenceTocYaml = """ + var referenceTocYaml = + """ toc: - file: index.md - file: api.md @@ -319,13 +325,14 @@ public void AllFileNavigationItemsHaveValidFileInfoForComplexMixedStructure() var fileRefs = docSet.TableOfContents.SelectMany(DocumentationSetFile.GetFileRefs).ToList(); foreach (var fileRef in fileRefs) { - var path = fileSystem.FileInfo.New(Path.Join(context.DocumentationSourceDirectory.FullName, fileRef.PathRelativeToDocumentationSet)); + var path = fileSystem.FileInfo.New( + Path.Join(context.DocumentationSourceDirectory.FullName, fileRef.PathRelativeToDocumentationSet) + ); path.Exists.Should().BeTrue($"Expected file {path.FullName} to exist"); } fileRefs.Count.Should().Be(fileRefs.Distinct().Count(), "should not have duplicate file references"); - var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); // Validate all file navigation items in this complex structure @@ -346,7 +353,8 @@ public void AllFileNavigationItemsHaveValidFileInfoForComplexMixedStructure() public void AllFileNavigationItemsHaveValidFileInfoForNestedFolders() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: docs @@ -388,7 +396,8 @@ public void AllFileNavigationItemsHaveValidFileInfoForNestedFolders() public void AllFileNavigationItemsHaveValidFileInfoForVirtualFilesWithNestedChildren() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: guide.md @@ -453,7 +462,9 @@ private static MockFileSystem CreateMockFileSystemWithFiles(string[] filePaths) /// Recursively collects all FileNavigationLeaf instances from the navigation tree. /// For VirtualFileNavigation items, extracts the Index (which is a FileNavigationLeaf). /// - private static HashSet> GetAllFileNavigationItems(INodeNavigationItem node) + private static HashSet> GetAllFileNavigationItems( + INodeNavigationItem node + ) { var result = new HashSet>(); if (node.Index is FileNavigationLeaf index) diff --git a/tests/Navigation.Tests/Isolation/FileNavigationTests.cs b/tests/Navigation.Tests/Isolation/FileNavigationTests.cs index 101184a255..cfa2df9eb2 100644 --- a/tests/Navigation.Tests/Isolation/FileNavigationTests.cs +++ b/tests/Navigation.Tests/Isolation/FileNavigationTests.cs @@ -18,7 +18,8 @@ public class FileNavigationTests(ITestOutputHelper output) : DocumentationSetNav public void FileWithNoChildrenCreatesFileNavigationLeaf() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: getting-started.md @@ -40,7 +41,8 @@ public void FileWithNoChildrenCreatesFileNavigationLeaf() public void FileWithChildrenCreatesFileNavigation() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: guide.md @@ -74,7 +76,8 @@ public void FileWithChildrenCreatesFileNavigation() public void FileWithChildrenDeeplinksPreservesPaths() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: nest/guide.md @@ -108,7 +111,8 @@ public void FileWithChildrenDeeplinksPreservesPaths() public void FileWithNestedChildrenBuildsCorrectly() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: guide.md @@ -144,7 +148,8 @@ public void FileWithNestedChildrenBuildsCorrectly() public void FileNavigationUrlUpdatesWhenRootChanges() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: guide.md @@ -177,7 +182,8 @@ public void FileNavigationUrlUpdatesWhenRootChanges() public void FileNavigationMixedWithFolderChildren() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: guide.md diff --git a/tests/Navigation.Tests/Isolation/FolderExcludeTests.cs b/tests/Navigation.Tests/Isolation/FolderExcludeTests.cs index 351851ad3b..1b87f33d24 100644 --- a/tests/Navigation.Tests/Isolation/FolderExcludeTests.cs +++ b/tests/Navigation.Tests/Isolation/FolderExcludeTests.cs @@ -15,7 +15,8 @@ public class FolderExcludeTests(ITestOutputHelper output) : DocumentationSetNavi public void FolderWithoutExcludeIncludesAllFiles() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: docs @@ -34,17 +35,15 @@ public void FolderWithoutExcludeIncludesAllFiles() var folderItem = docSet.TableOfContents.First().Should().BeOfType().Subject; var fileNames = folderItem.Children.Select(c => c.PathRelativeToDocumentationSet).ToList(); - fileNames.Should().BeEquivalentTo( - ["docs/alpha.md", "docs/beta.md", "docs/gamma.md"], - options => options.WithStrictOrdering() - ); + fileNames.Should().BeEquivalentTo(["docs/alpha.md", "docs/beta.md", "docs/gamma.md"], options => options.WithStrictOrdering()); } [Fact] public void FolderWithExcludeFiltersOutSpecifiedFiles() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: docs @@ -65,17 +64,15 @@ public void FolderWithExcludeFiltersOutSpecifiedFiles() var folderItem = docSet.TableOfContents.First().Should().BeOfType().Subject; var fileNames = folderItem.Children.Select(c => c.PathRelativeToDocumentationSet).ToList(); - fileNames.Should().BeEquivalentTo( - ["docs/alpha.md", "docs/gamma.md"], - options => options.WithStrictOrdering() - ); + fileNames.Should().BeEquivalentTo(["docs/alpha.md", "docs/gamma.md"], options => options.WithStrictOrdering()); } [Fact] public void FolderWithExcludeMultipleFiles() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: docs @@ -104,7 +101,8 @@ public void FolderWithExcludeMultipleFiles() public void FolderWithExcludeIsCaseInsensitive() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: docs @@ -131,7 +129,8 @@ public void FolderWithExcludeIsCaseInsensitive() public void FolderWithExcludeCanExcludeIndexMd() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: docs @@ -161,7 +160,8 @@ public void FolderWithExcludeCanExcludeIndexMd() public void FolderExcludePopulatesFolderExcludedFiles() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: docs @@ -187,7 +187,8 @@ public void FolderExcludePopulatesFolderExcludedFiles() public void FolderExcludeCollectsFromNestedFolders() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: reference @@ -215,8 +216,6 @@ public void FolderExcludeCollectsFromNestedFolders() var context = CreateContext(fileSystem); var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); - docSet.FolderExcludedFiles.Should().BeEquivalentTo( - ["reference/api/main.md", "reference/deps/main.md"] - ); + docSet.FolderExcludedFiles.Should().BeEquivalentTo(["reference/api/main.md", "reference/deps/main.md"]); } } diff --git a/tests/Navigation.Tests/Isolation/FolderIndexFileRefTests.cs b/tests/Navigation.Tests/Isolation/FolderIndexFileRefTests.cs index a7d55baef4..0596dd5d14 100644 --- a/tests/Navigation.Tests/Isolation/FolderIndexFileRefTests.cs +++ b/tests/Navigation.Tests/Isolation/FolderIndexFileRefTests.cs @@ -18,7 +18,8 @@ public class FolderIndexFileRefTests(ITestOutputHelper output) : DocumentationSe public async Task FolderWithFileCreatesCorrectStructure() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: getting-started @@ -54,7 +55,8 @@ public async Task FolderWithFileCreatesCorrectStructure() public async Task FolderWithFileChildrenPathsAreScopedToFolder() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: getting-started @@ -88,7 +90,8 @@ public async Task FolderWithFileChildrenPathsAreScopedToFolder() public async Task FolderWithFileEmitsHintWhenFileNameDoesNotMatchFolder() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: getting-started @@ -110,18 +113,19 @@ public async Task FolderWithFileEmitsHintWhenFileNameDoesNotMatchFolder() // Should emit hint about file name not matching folder name context.Collector.Hints.Should().BeGreaterThan(0); var diagnostics = context.Diagnostics; - diagnostics.Should().Contain(d => - d.Severity == Severity.Hint && - d.Message.Contains("intro.md") && - d.Message.Contains("getting-started") && - d.Message.Contains("Best practice")); + diagnostics.Should().Contain( + d => + d.Severity == Severity.Hint && d.Message.Contains("intro.md") && d.Message.Contains("getting-started") && + d.Message.Contains("Best practice") + ); } [Fact] public async Task FolderWithFileDoesNotEmitHintWhenFileNameMatchesFolder() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: getting-started @@ -149,7 +153,8 @@ public async Task FolderWithFileDoesNotEmitHintWhenFileNameMatchesFolder() public async Task FolderWithFileEmitsErrorForDeepLinkingInFile() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: getting-started @@ -171,17 +176,19 @@ public async Task FolderWithFileEmitsErrorForDeepLinkingInFile() // Should emit error about deep linking in the file attribute context.Collector.Errors.Should().BeGreaterThan(0); var diagnostics = context.Diagnostics; - diagnostics.Should().Contain(d => - d.Severity == Severity.Error && - d.Message.Contains("Deep linking on folder 'file' is not supported") && - d.Message.Contains("intro/file.md")); + diagnostics.Should().Contain( + d => + d.Severity == Severity.Error && d.Message.Contains("Deep linking on folder 'file' is not supported") && + d.Message.Contains("intro/file.md") + ); } [Fact] public async Task FolderWithIndexMdFileDoesNotNeedToMatchFolderName() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: getting-started @@ -209,7 +216,8 @@ public async Task FolderWithIndexMdFileDoesNotNeedToMatchFolderName() public async Task FolderWithFileCaseInsensitiveMatch() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: GettingStarted diff --git a/tests/Navigation.Tests/Isolation/FolderSortOrderTests.cs b/tests/Navigation.Tests/Isolation/FolderSortOrderTests.cs index 96f2e1cef0..b74312c937 100644 --- a/tests/Navigation.Tests/Isolation/FolderSortOrderTests.cs +++ b/tests/Navigation.Tests/Isolation/FolderSortOrderTests.cs @@ -16,7 +16,8 @@ public class FolderSortOrderTests(ITestOutputHelper output) : DocumentationSetNa public void FolderWithDefaultSortOrderIsAscending() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: api-versions @@ -36,14 +37,18 @@ public void FolderWithDefaultSortOrderIsAscending() folderItem.Sort.Should().BeNull(); var fileNames = folderItem.Children.Select(c => c.PathRelativeToDocumentationSet).ToList(); - fileNames.Should().BeEquivalentTo(["api-versions/v1.md", "api-versions/v2.md", "api-versions/v3.md"], options => options.WithStrictOrdering()); + fileNames.Should().BeEquivalentTo( + ["api-versions/v1.md", "api-versions/v2.md", "api-versions/v3.md"], + options => options.WithStrictOrdering() + ); } [Fact] public void FolderWithSortDescendingOrdersFilesZToA() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: api-versions @@ -64,14 +69,18 @@ public void FolderWithSortDescendingOrdersFilesZToA() folderItem.Sort.Should().Be("desc"); var fileNames = folderItem.Children.Select(c => c.PathRelativeToDocumentationSet).ToList(); - fileNames.Should().BeEquivalentTo(["api-versions/v3.md", "api-versions/v2.md", "api-versions/v1.md"], options => options.WithStrictOrdering()); + fileNames.Should().BeEquivalentTo( + ["api-versions/v3.md", "api-versions/v2.md", "api-versions/v1.md"], + options => options.WithStrictOrdering() + ); } [Fact] public void FolderWithSortDescendingLongFormOrdersFilesZToA() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: api-versions @@ -92,14 +101,18 @@ public void FolderWithSortDescendingLongFormOrdersFilesZToA() folderItem.Sort.Should().Be("descending"); var fileNames = folderItem.Children.Select(c => c.PathRelativeToDocumentationSet).ToList(); - fileNames.Should().BeEquivalentTo(["api-versions/v3.md", "api-versions/v2.md", "api-versions/v1.md"], options => options.WithStrictOrdering()); + fileNames.Should().BeEquivalentTo( + ["api-versions/v3.md", "api-versions/v2.md", "api-versions/v1.md"], + options => options.WithStrictOrdering() + ); } [Fact] public void FolderWithSortAscendingExplicitOrdersFilesAToZ() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: api-versions @@ -120,14 +133,18 @@ public void FolderWithSortAscendingExplicitOrdersFilesAToZ() folderItem.Sort.Should().Be("asc"); var fileNames = folderItem.Children.Select(c => c.PathRelativeToDocumentationSet).ToList(); - fileNames.Should().BeEquivalentTo(["api-versions/v1.md", "api-versions/v2.md", "api-versions/v3.md"], options => options.WithStrictOrdering()); + fileNames.Should().BeEquivalentTo( + ["api-versions/v1.md", "api-versions/v2.md", "api-versions/v3.md"], + options => options.WithStrictOrdering() + ); } [Fact] public void FolderWithSortDescendingPlacesIndexMdFirst() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: api-versions @@ -159,7 +176,8 @@ public void FolderWithSortDescendingPlacesIndexMdFirst() public void FolderWithFileAndSortDescendingPreservesSortOrder() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: api-versions @@ -182,7 +200,8 @@ public void FolderWithFileAndSortDescendingPreservesSortOrder() public void FolderWithExplicitChildrenIgnoresSortOrder() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: api-versions @@ -203,14 +222,18 @@ public void FolderWithExplicitChildrenIgnoresSortOrder() var folderItem = docSet.TableOfContents.First().Should().BeOfType().Subject; var fileNames = folderItem.Children.Select(c => c.PathRelativeToDocumentationSet).ToList(); - fileNames.Should().BeEquivalentTo(["api-versions/v1.md", "api-versions/v2.md", "api-versions/v3.md"], options => options.WithStrictOrdering()); + fileNames.Should().BeEquivalentTo( + ["api-versions/v1.md", "api-versions/v2.md", "api-versions/v3.md"], + options => options.WithStrictOrdering() + ); } [Fact] public void FolderWithUnrecognizedSortValueEmitsError() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: api-versions @@ -225,16 +248,15 @@ public void FolderWithUnrecognizedSortValueEmitsError() var context = CreateContext(fileSystem); _ = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); - context.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && - d.Message.Contains("Unknown sort order 'newest'")); + context.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("Unknown sort order 'newest'")); } [Fact] public void FolderSortUsesNaturalOrderForVersionNumbers() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: api-versions @@ -265,7 +287,8 @@ public void FolderSortUsesNaturalOrderForVersionNumbers() public void FolderSortDescendingUsesNaturalOrderForVersionNumbers() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: api-versions diff --git a/tests/Navigation.Tests/Isolation/IslandNavigationTests.cs b/tests/Navigation.Tests/Isolation/IslandNavigationTests.cs index e668ba41ca..5c78e6e472 100644 --- a/tests/Navigation.Tests/Isolation/IslandNavigationTests.cs +++ b/tests/Navigation.Tests/Isolation/IslandNavigationTests.cs @@ -23,7 +23,8 @@ public class IslandNavigationTests(ITestOutputHelper output) : DocumentationSetN public void DocsetRootIsland_IsNotAnIsland_InIsolatedBuild() { // language=yaml - var yaml = """ + var yaml = + """ island: true project: 'docs-builder' toc: @@ -37,7 +38,12 @@ public void DocsetRootIsland_IsNotAnIsland_InIsolatedBuild() var context = CreateContext(fileSystem); var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); - var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance, crossLinkResolver: TestCrossLinkResolver.Instance); + var navigation = new DocumentationSetNavigation( + docSet, + context, + TestDocumentationFileFactory.Instance, + crossLinkResolver: TestCrossLinkResolver.Instance + ); // IsIsland is stored but RendersAsIsland() returns false because Parent is null navigation.IsIsland.Should().BeTrue("docset declared island: true"); @@ -53,7 +59,8 @@ public void DocsetRootIsland_IsNotAnIsland_InIsolatedBuild() public async Task NestedTocIsland_RendersAsIsland() { // language=yaml - var yaml = """ + var yaml = + """ project: 'docs-builder' toc: - file: index.md @@ -64,31 +71,42 @@ public async Task NestedTocIsland_RendersAsIsland() fileSystem.AddFile("/docs/index.md", new MockFileData("# Root")); fileSystem.AddFile("/docs/reference/index.md", new MockFileData("# Reference")); fileSystem.AddFile("/docs/reference/page.md", new MockFileData("# Page")); - fileSystem.AddFile("/docs/reference/toc.yml", new MockFileData( - // language=yaml - """ + fileSystem.AddFile( + "/docs/reference/toc.yml", + new MockFileData( + // language=yaml + """ island: true toc: - file: index.md - file: page.md - """)); + """ + ) + ); var context = CreateContext(fileSystem); var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); - var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance, crossLinkResolver: TestCrossLinkResolver.Instance); + var navigation = new DocumentationSetNavigation( + docSet, + context, + TestDocumentationFileFactory.Instance, + crossLinkResolver: TestCrossLinkResolver.Instance + ); await context.Collector.StopAsync(TestContext.Current.CancellationToken); - var reference = navigation.NavigationItems.ElementAt(0) - .Should().BeOfType>().Subject; + var reference = navigation.NavigationItems + .ElementAt(0) + .Should() + .BeOfType>() + .Subject; reference.IsIsland.Should().BeTrue(); reference.Parent.Should().NotBeNull("reference is a child of the docset"); reference.RendersAsIsland().Should().BeTrue(); // Pages inside the island can find the island root via FindIslandRoot - var page = reference.NavigationItems.ElementAt(0) - .Should().BeOfType>().Subject; + var page = reference.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; page.FindIslandRoot().Should().BeSameAs(reference); context.Diagnostics.Should().BeEmpty(); @@ -101,7 +119,8 @@ public async Task NestedTocIsland_RendersAsIsland() public async Task InlineTocEntryIsland_RendersAsIsland() { // language=yaml - var yaml = """ + var yaml = + """ project: 'docs-builder' toc: - file: index.md @@ -113,22 +132,34 @@ public async Task InlineTocEntryIsland_RendersAsIsland() fileSystem.AddFile("/docs/index.md", new MockFileData("# Root")); fileSystem.AddFile("/docs/advanced/index.md", new MockFileData("# Advanced")); fileSystem.AddFile("/docs/advanced/deep.md", new MockFileData("# Deep")); - fileSystem.AddFile("/docs/advanced/toc.yml", new MockFileData( - // language=yaml - """ + fileSystem.AddFile( + "/docs/advanced/toc.yml", + new MockFileData( + // language=yaml + """ toc: - file: index.md - file: deep.md - """)); + """ + ) + ); var context = CreateContext(fileSystem); var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); - var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance, crossLinkResolver: TestCrossLinkResolver.Instance); + var navigation = new DocumentationSetNavigation( + docSet, + context, + TestDocumentationFileFactory.Instance, + crossLinkResolver: TestCrossLinkResolver.Instance + ); await context.Collector.StopAsync(TestContext.Current.CancellationToken); - var advanced = navigation.NavigationItems.ElementAt(0) - .Should().BeOfType>().Subject; + var advanced = navigation.NavigationItems + .ElementAt(0) + .Should() + .BeOfType>() + .Subject; advanced.IsIsland.Should().BeTrue("inline island: true propagates to the resolved toc ref"); advanced.RendersAsIsland().Should().BeTrue(); @@ -143,7 +174,8 @@ public async Task InlineTocEntryIsland_RendersAsIsland() public async Task Island_OrSemantics_BothSidesWork() { // language=yaml - var yamlInlineOnly = """ + var yamlInlineOnly = + """ project: 'docs-builder' toc: - file: index.md @@ -151,7 +183,8 @@ public async Task Island_OrSemantics_BothSidesWork() island: true """; // language=yaml - var yamlTocYmlOnly = """ + var yamlTocYmlOnly = + """ project: 'docs-builder' toc: - file: index.md @@ -159,7 +192,8 @@ public async Task Island_OrSemantics_BothSidesWork() """; // language=yaml - var tocYmlIsland = """ + var tocYmlIsland = + """ island: true toc: - file: index.md @@ -179,7 +213,12 @@ async Task> Build(string docset var ctx = CreateContext(fs); var docSet = DocumentationSetFile.LoadAndResolve(ctx.Collector, docsetYaml, fs.NewDirInfo("docs")); _ = ctx.Collector.StartAsync(TestContext.Current.CancellationToken); - var nav = new DocumentationSetNavigation(docSet, ctx, TestDocumentationFileFactory.Instance, crossLinkResolver: TestCrossLinkResolver.Instance); + var nav = new DocumentationSetNavigation( + docSet, + ctx, + TestDocumentationFileFactory.Instance, + crossLinkResolver: TestCrossLinkResolver.Instance + ); await ctx.Collector.StopAsync(TestContext.Current.CancellationToken); return nav.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; } @@ -201,7 +240,8 @@ async Task> Build(string docset public async Task FindIslandRoot_ReturnsNearestIsland_WhenIslandsNest() { // language=yaml - var yaml = """ + var yaml = + """ project: 'docs-builder' toc: - file: index.md @@ -214,39 +254,53 @@ public async Task FindIslandRoot_ReturnsNearestIsland_WhenIslandsNest() fileSystem.AddFile("/docs/security/index.md", new MockFileData("# Security")); fileSystem.AddFile("/docs/security/rules/index.md", new MockFileData("# Rules")); fileSystem.AddFile("/docs/security/rules/page.md", new MockFileData("# Page")); - fileSystem.AddFile("/docs/security/toc.yml", new MockFileData( - // language=yaml - """ + fileSystem.AddFile( + "/docs/security/toc.yml", + new MockFileData( + // language=yaml + """ toc: - file: index.md - toc: rules island: true - """)); - fileSystem.AddFile("/docs/security/rules/toc.yml", new MockFileData( - // language=yaml """ + ) + ); + fileSystem.AddFile( + "/docs/security/rules/toc.yml", + new MockFileData( + // language=yaml + """ toc: - file: index.md - file: page.md - """)); + """ + ) + ); var context = CreateContext(fileSystem); var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); - var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance, crossLinkResolver: TestCrossLinkResolver.Instance); + var navigation = new DocumentationSetNavigation( + docSet, + context, + TestDocumentationFileFactory.Instance, + crossLinkResolver: TestCrossLinkResolver.Instance + ); await context.Collector.StopAsync(TestContext.Current.CancellationToken); - var security = navigation.NavigationItems.ElementAt(0) - .Should().BeOfType>().Subject; + var security = navigation.NavigationItems + .ElementAt(0) + .Should() + .BeOfType>() + .Subject; security.RendersAsIsland().Should().BeTrue("security is an island"); - var rules = security.NavigationItems.ElementAt(0) - .Should().BeOfType>().Subject; + var rules = security.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; rules.RendersAsIsland().Should().BeTrue("rules is a nested island"); // A page inside the rules island → nearest island is rules (not security) - var page = rules.NavigationItems.ElementAt(0) - .Should().BeOfType>().Subject; + var page = rules.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; page.FindIslandRoot().Should().BeSameAs(rules, "FindIslandRoot returns the nearest enclosing island"); // The rules index page → nearest island is also rules @@ -265,7 +319,8 @@ public async Task FindIslandRoot_ReturnsNearestIsland_WhenIslandsNest() public async Task ListingIsland_MarksListingRootAsIsland() { // language=yaml - var yaml = """ + var yaml = + """ project: 'docs-builder' toc: - file: index.md @@ -284,12 +339,16 @@ public async Task ListingIsland_MarksListingRootAsIsland() var context = CreateContext(fileSystem); var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); - var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance, crossLinkResolver: TestCrossLinkResolver.Instance); + var navigation = new DocumentationSetNavigation( + docSet, + context, + TestDocumentationFileFactory.Instance, + crossLinkResolver: TestCrossLinkResolver.Instance + ); await context.Collector.StopAsync(TestContext.Current.CancellationToken); // Listing island root - var listingRoot = navigation.NavigationItems.ElementAt(0) - .Should().BeOfType>().Subject; + var listingRoot = navigation.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; listingRoot.IsIsland.Should().BeTrue(); listingRoot.RendersAsIsland().Should().BeTrue("listing root has a parent"); @@ -307,7 +366,8 @@ public async Task ListingIsland_MarksListingRootAsIsland() public async Task ListingIsland_WithVisualNone_EmitsError() { // language=yaml - var yaml = """ + var yaml = + """ project: 'docs-builder' toc: - file: index.md @@ -324,12 +384,18 @@ public async Task ListingIsland_WithVisualNone_EmitsError() var context = CreateContext(fileSystem); var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); - var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance, crossLinkResolver: TestCrossLinkResolver.Instance); + var navigation = new DocumentationSetNavigation( + docSet, + context, + TestDocumentationFileFactory.Instance, + crossLinkResolver: TestCrossLinkResolver.Instance + ); await context.Collector.StopAsync(TestContext.Current.CancellationToken); // The listing with island: true and no visual: should emit an error - context.Diagnostics.Should().ContainSingle(d => d.Severity == Severity.Error - && d.Message.Contains("island: true") && d.Message.Contains("visual: none")); + context.Diagnostics + .Should() + .ContainSingle(d => d.Severity == Severity.Error && d.Message.Contains("island: true") && d.Message.Contains("visual: none")); // And the listing is not added to navigation navigation.NavigationItems.Should().BeEmpty(); } diff --git a/tests/Navigation.Tests/Isolation/NaturalStringComparerTests.cs b/tests/Navigation.Tests/Isolation/NaturalStringComparerTests.cs index cbaded6bd6..4191bcb241 100644 --- a/tests/Navigation.Tests/Isolation/NaturalStringComparerTests.cs +++ b/tests/Navigation.Tests/Isolation/NaturalStringComparerTests.cs @@ -44,10 +44,7 @@ public void VersionNumbersWithUnderscores() var files = new[] { "3_10_0.md", "3_2_0.md", "3_1_0.md", "3_0_0.md" }; var sorted = files.OrderBy(f => f, Comparer).ToList(); - sorted.Should().BeEquivalentTo( - ["3_0_0.md", "3_1_0.md", "3_2_0.md", "3_10_0.md"], - options => options.WithStrictOrdering() - ); + sorted.Should().BeEquivalentTo(["3_0_0.md", "3_1_0.md", "3_2_0.md", "3_10_0.md"], options => options.WithStrictOrdering()); } [Fact] @@ -56,10 +53,7 @@ public void VersionNumbersWithDots() var files = new[] { "3.10.0.md", "3.2.0.md", "3.1.0.md", "3.0.0.md" }; var sorted = files.OrderBy(f => f, Comparer).ToList(); - sorted.Should().BeEquivalentTo( - ["3.0.0.md", "3.1.0.md", "3.2.0.md", "3.10.0.md"], - options => options.WithStrictOrdering() - ); + sorted.Should().BeEquivalentTo(["3.0.0.md", "3.1.0.md", "3.2.0.md", "3.10.0.md"], options => options.WithStrictOrdering()); } [Fact] @@ -86,10 +80,7 @@ public void MixedPrefixesWithNumbers() var files = new[] { "file2.md", "file10.md", "file1.md" }; var sorted = files.OrderBy(f => f, Comparer).ToList(); - sorted.Should().BeEquivalentTo( - ["file1.md", "file2.md", "file10.md"], - options => options.WithStrictOrdering() - ); + sorted.Should().BeEquivalentTo(["file1.md", "file2.md", "file10.md"], options => options.WithStrictOrdering()); } [Fact] diff --git a/tests/Navigation.Tests/Isolation/NavigationStructureTests.cs b/tests/Navigation.Tests/Isolation/NavigationStructureTests.cs index a1b68d4416..5259af2858 100644 --- a/tests/Navigation.Tests/Isolation/NavigationStructureTests.cs +++ b/tests/Navigation.Tests/Isolation/NavigationStructureTests.cs @@ -18,7 +18,8 @@ public class NavigationStructureTests(ITestOutputHelper output) : DocumentationS public void NavigationIndexIsSetCorrectly() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: first.md @@ -43,7 +44,8 @@ public void NavigationIndexIsSetCorrectly() public void CanQueryNavigationForBothInterfaceAndConcreteTypes() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: first.md @@ -63,12 +65,14 @@ public void CanQueryNavigationForBothInterfaceAndConcreteTypes() var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); // Query for all leaf items using the base interface type - var allLeafItems = navigation.NavigationItems.Concat([navigation.Index]) - .SelectMany(item => item is INodeNavigationItem node - ? node.NavigationItems.OfType>().Concat([node.Index]) - : item is ILeafNavigationItem leaf - ? [leaf] - : []) + var allLeafItems = navigation.NavigationItems + .Concat([navigation.Index]) + .SelectMany( + item => + item is INodeNavigationItem node + ? node.NavigationItems.OfType>().Concat([node.Index]) + : item is ILeafNavigationItem leaf ? [leaf] : [] + ) .ToList(); // All items are queryable as ILeafNavigationItem due to covariance @@ -109,7 +113,8 @@ public void CanQueryNavigationForBothInterfaceAndConcreteTypes() public async Task ComplexNestedStructureBuildsCorrectly() { // language=yaml - var yaml = """ + var yaml = + """ project: 'docs-builder' features: primary-nav: true @@ -126,16 +131,21 @@ public async Task ComplexNestedStructureBuildsCorrectly() var fileSystem = new MockFileSystem(); fileSystem.AddDirectory("/docs/setup/advanced"); fileSystem.AddDirectory("/docs/setup/advanced/performance"); - fileSystem.AddFile("/docs/setup/advanced/toc.yml", new MockFileData( - // language=yaml - """ + fileSystem.AddFile( + "/docs/setup/advanced/toc.yml", + new MockFileData( + // language=yaml + """ toc: - file: index.md - toc: performance - """)); + """ + ) + ); // language=yaml - var performanceTocYaml = """ + var performanceTocYaml = + """ toc: - file: index.md - file: tuning.md @@ -151,7 +161,12 @@ public async Task ComplexNestedStructureBuildsCorrectly() var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); - var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance, crossLinkResolver: TestCrossLinkResolver.Instance); + var navigation = new DocumentationSetNavigation( + docSet, + context, + TestDocumentationFileFactory.Instance, + crossLinkResolver: TestCrossLinkResolver.Instance + ); await context.Collector.StopAsync(TestContext.Current.CancellationToken); @@ -170,7 +185,11 @@ public async Task ComplexNestedStructureBuildsCorrectly() var setupIndex = setupFolder.Index.Should().BeOfType>().Subject; setupIndex.Url.Should().Be("/setup"); // index.md becomes /setup - var advancedToc = setupFolder.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + var advancedToc = setupFolder.NavigationItems + .ElementAt(0) + .Should() + .BeOfType>() + .Subject; advancedToc.Url.Should().Be("/setup/advanced"); // Advanced TOC has index.md and the nested performance TOC as children advancedToc.NavigationItems.Should().HaveCount(1); @@ -178,7 +197,11 @@ public async Task ComplexNestedStructureBuildsCorrectly() var advancedIndex = advancedToc.Index.Should().BeOfType>().Subject; advancedIndex.Url.Should().Be("/setup/advanced"); - var performanceToc = advancedToc.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + var performanceToc = advancedToc.NavigationItems + .ElementAt(0) + .Should() + .BeOfType>() + .Subject; performanceToc.Url.Should().Be("/setup/advanced/performance"); performanceToc.NavigationItems.Should().HaveCount(2); @@ -205,7 +228,8 @@ public void NestedTocUrlsDoNotDuplicatePath() // without duplicating path segments (e.g., /setup/advanced not /setup/setup/advanced) // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: setup @@ -215,14 +239,16 @@ public void NestedTocUrlsDoNotDuplicatePath() """; // language=yaml - var advancedTocYaml = """ + var advancedTocYaml = + """ toc: - file: index.md - toc: performance """; // language=yaml - var performanceTocYaml = """ + var performanceTocYaml = + """ toc: - file: index.md """; @@ -245,14 +271,22 @@ public void NestedTocUrlsDoNotDuplicatePath() // Setup folder has index.md and advanced TOC setupFolder.NavigationItems.Should().HaveCount(1); - var advancedToc = setupFolder.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + var advancedToc = setupFolder.NavigationItems + .ElementAt(0) + .Should() + .BeOfType>() + .Subject; // Verify the URL is /setup/advanced and not /setup/setup/advanced advancedToc.Url.Should().Be("/setup/advanced"); // Advanced TOC has index.md and performance TOC advancedToc.NavigationItems.Should().HaveCount(1); - var performanceToc = advancedToc.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + var performanceToc = advancedToc.NavigationItems + .ElementAt(0) + .Should() + .BeOfType>() + .Subject; // Verify the URL is /setup/advanced/performance and not /setup/advanced/setup/advanced/performance performanceToc.Url.Should().Be("/setup/advanced/performance"); @@ -263,7 +297,8 @@ public void NestedTocUrlsDoNotDuplicatePath() public void AllNavigationItemsHaveNavigationRootSet() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: index.md @@ -280,7 +315,8 @@ public void AllNavigationItemsHaveNavigationRootSet() """; // language=yaml - var advancedTocYaml = """ + var advancedTocYaml = + """ toc: - file: index.md - file: configuration.md @@ -295,7 +331,12 @@ public void AllNavigationItemsHaveNavigationRootSet() var context = CreateContext(fileSystem); var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); - var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance, crossLinkResolver: TestCrossLinkResolver.Instance); + var navigation = new DocumentationSetNavigation( + docSet, + context, + TestDocumentationFileFactory.Instance, + crossLinkResolver: TestCrossLinkResolver.Instance + ); // Helper to recursively visit all navigation items var allItems = new List(); @@ -324,8 +365,11 @@ void VisitNavigationItems(INavigationItem item) item.NavigationRoot.Should().NotBeNull($"NavigationRoot should be set for {item.GetType().Name} at URL: {item.Url}"); // Verify NavigationRoot is actually a root item - item.NavigationRoot.Should().BeAssignableTo>( - $"NavigationRoot should be a root navigation item for {item.GetType().Name} at URL: {item.Url}"); + item.NavigationRoot + .Should() + .BeAssignableTo>( + $"NavigationRoot should be a root navigation item for {item.GetType().Name} at URL: {item.Url}" + ); } // Verify specific cases: @@ -343,11 +387,19 @@ void VisitNavigationItems(INavigationItem item) // According to url-building.md: "In isolated builds the NavigationRoot is always the DocumentationSetNavigation" // ALL items including TOCs should point to DocumentationSetNavigation as NavigationRoot - var advancedToc = setupFolder.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; - advancedToc.NavigationRoot.Should().BeSameAs(navigation, "TOC NavigationRoot should be DocumentationSetNavigation in isolated builds"); + var advancedToc = setupFolder.NavigationItems + .ElementAt(0) + .Should() + .BeOfType>() + .Subject; + advancedToc.NavigationRoot + .Should() + .BeSameAs(navigation, "TOC NavigationRoot should be DocumentationSetNavigation in isolated builds"); var advancedIndex = advancedToc.NavigationItems.First(); - advancedIndex.NavigationRoot.Should().BeSameAs(navigation, "TOC children should point to DocumentationSetNavigation in isolated builds"); + advancedIndex.NavigationRoot + .Should() + .BeSameAs(navigation, "TOC children should point to DocumentationSetNavigation in isolated builds"); // Items in file with children should point to DocumentationSetNavigation var guideFile = navigation.NavigationItems.ElementAt(1).Should().BeOfType>().Subject; @@ -362,5 +414,4 @@ void VisitNavigationItems(INavigationItem item) context.Diagnostics.Should().BeEmpty(); } - } diff --git a/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs b/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs index 811ad530fe..3d2b9d3d6b 100644 --- a/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs +++ b/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs @@ -29,11 +29,21 @@ public async Task PhysicalDocsetCanBeNavigated() var configPath = fileSystem.FileInfo.New(docsetPath); var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); - var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, context.ReadFileSystem as ScopedFileSystem, noSuppress: [HintType.DeepLinkingVirtualFile]); + var docSet = DocumentationSetFile.LoadAndResolve( + context.Collector, + configPath, + context.ReadFileSystem as ScopedFileSystem, + noSuppress: [HintType.DeepLinkingVirtualFile] + ); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); - var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance, crossLinkResolver: TestCrossLinkResolver.Instance); + var navigation = new DocumentationSetNavigation( + docSet, + context, + TestDocumentationFileFactory.Instance, + crossLinkResolver: TestCrossLinkResolver.Instance + ); await context.Collector.StopAsync(TestContext.Current.CancellationToken); @@ -69,7 +79,11 @@ public async Task PhysicalDocsetNavigationHasCorrectUrls() var configPath = fileSystem.FileInfo.New(docsetPath); var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); - var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName)); + var docSet = DocumentationSetFile.LoadAndResolve( + context.Collector, + configPath, + DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName) + ); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); @@ -77,7 +91,8 @@ public async Task PhysicalDocsetNavigationHasCorrectUrls() await context.Collector.StopAsync(TestContext.Current.CancellationToken); // Find the documentation folder by URL - var documentationFolder = navigation.NavigationItems.OfType>() + var documentationFolder = navigation.NavigationItems + .OfType>() .FirstOrDefault(f => f.Url == "/documentation"); documentationFolder.Should().NotBeNull(); @@ -95,7 +110,11 @@ public async Task PhysicalDocsetNavigationIncludesNestedTocs() var configPath = fileSystem.FileInfo.New(docsetPath); var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); - var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName)); + var docSet = DocumentationSetFile.LoadAndResolve( + context.Collector, + configPath, + DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName) + ); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); @@ -111,7 +130,8 @@ public async Task PhysicalDocsetNavigationIncludesNestedTocs() fileRefs.Count.Should().Be(fileRefs.Distinct().Count(), "should not have duplicate file references"); // development is a toc: reference - var developmentToc = navigation.NavigationItems.OfType>() + var developmentToc = navigation.NavigationItems + .OfType>() .FirstOrDefault(t => t.Url == "/development"); developmentToc.Should().NotBeNull(); developmentToc.NavigationItems.Should().NotBeEmpty(); @@ -127,7 +147,11 @@ public async Task PhysicalDocsetNavigationHandlesHiddenFiles() var configPath = fileSystem.FileInfo.New(docsetPath); var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); - var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName)); + var docSet = DocumentationSetFile.LoadAndResolve( + context.Collector, + configPath, + DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName) + ); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); @@ -155,7 +179,12 @@ public async Task PhysicalTestDocsetNavigationHandlesCrossLinks() var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); - var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance, crossLinkResolver: TestCrossLinkResolver.Instance); + var navigation = new DocumentationSetNavigation( + docSet, + context, + TestDocumentationFileFactory.Instance, + crossLinkResolver: TestCrossLinkResolver.Instance + ); await context.Collector.StopAsync(TestContext.Current.CancellationToken); @@ -189,8 +218,9 @@ public void CovarianceOfNavigationItemsIsRespected() interfaces.Count.Should().Be(concrete.Count); } - private static List QueryAllAdheringTo(INodeNavigationItem navigation) - where TModel : class, INavigationModel + private static List QueryAllAdheringTo( + INodeNavigationItem navigation + ) where TModel : class, INavigationModel { var result = new List { navigation, navigation.Index }; foreach (var item in navigation.NavigationItems) diff --git a/tests/Navigation.Tests/Isolation/ValidationTests.cs b/tests/Navigation.Tests/Isolation/ValidationTests.cs index e617db30bb..4849ac908d 100644 --- a/tests/Navigation.Tests/Isolation/ValidationTests.cs +++ b/tests/Navigation.Tests/Isolation/ValidationTests.cs @@ -19,7 +19,8 @@ public class ValidationTests(ITestOutputHelper output) : DocumentationSetNavigat public async Task ValidationEmitsErrorWhenTableOfContentsHasNonTocChildrenAndNestedTocNotAllowed() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - toc: api @@ -41,16 +42,17 @@ public async Task ValidationEmitsErrorWhenTableOfContentsHasNonTocChildrenAndNes await context.Collector.StopAsync(TestContext.Current.CancellationToken); var diagnostics = context.Diagnostics; - diagnostics.Should().Contain(d => - d.Message.Contains("may not contain children, define children in") && - d.Message.Contains("toc.yml")); + diagnostics.Should().Contain( + d => d.Message.Contains("may not contain children, define children in") && d.Message.Contains("toc.yml") + ); } [Fact] public async Task ValidationEmitsErrorWhenTableOfContentsHasNonTocChildren() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - toc: api @@ -71,16 +73,17 @@ public async Task ValidationEmitsErrorWhenTableOfContentsHasNonTocChildren() // Check using Errors count instead of Diagnostics collection context.Collector.Errors.Should().BeGreaterThan(0); var diagnostics = context.Diagnostics; - diagnostics.Should().Contain(d => - d.Message.Contains("may not contain children, define children in") && - d.Message.Contains("toc.yml")); + diagnostics.Should().Contain( + d => d.Message.Contains("may not contain children, define children in") && d.Message.Contains("toc.yml") + ); } [Fact] public void ValidationEmitsErrorForNestedTocWithFileChildren() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: setup @@ -102,16 +105,17 @@ public void ValidationEmitsErrorForNestedTocWithFileChildren() // Nested TOC under a root-level TOC should not allow file children var diagnostics = context.Diagnostics; - diagnostics.Should().Contain(d => - d.Message.Contains("may not contain children, define children in") && - d.Message.Contains("toc.yml")); + diagnostics.Should().Contain( + d => d.Message.Contains("may not contain children, define children in") && d.Message.Contains("toc.yml") + ); } [Fact] public async Task ValidationEmitsErrorForDeeplyNestedFolderWithInvalidTocStructure() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - folder: docs @@ -138,9 +142,9 @@ public async Task ValidationEmitsErrorForDeeplyNestedFolderWithInvalidTocStructu // Nested TOC structure under folders should still validate correctly var diagnostics = context.Diagnostics; - diagnostics.Should().Contain(d => - d.Message.Contains("may not contain children, define children in") && - d.Message.Contains("toc.yml")); + diagnostics.Should().Contain( + d => d.Message.Contains("may not contain children, define children in") && d.Message.Contains("toc.yml") + ); } [Fact] @@ -165,16 +169,17 @@ public async Task ValidationEmitsErrorWhenTocYmlFileNotFound() await context.Collector.StopAsync(TestContext.Current.CancellationToken); var diagnostics = context.Diagnostics; - diagnostics.Should().ContainSingle(d => - d.Message.Contains("Table of contents file not found") && - d.Message.Contains("api/toc.yml")); + diagnostics.Should().ContainSingle( + d => d.Message.Contains("Table of contents file not found") && d.Message.Contains("api/toc.yml") + ); } [Fact] public async Task ValidationEmitsHintForDeepLinkingVirtualFiles() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: a/b/c/getting-started.md @@ -195,18 +200,19 @@ public async Task ValidationEmitsHintForDeepLinkingVirtualFiles() context.Collector.Hints.Should().BeGreaterThan(0, "should have emitted a hint for deep-linking virtual file"); var diagnostics = context.Diagnostics; - diagnostics.Should().Contain(d => - d.Severity == Severity.Hint && - d.Message.Contains("a/b/c/getting-started.md") && - d.Message.Contains("deep-linking") && - d.Message.Contains("folder")); + diagnostics.Should().Contain( + d => + d.Severity == Severity.Hint && d.Message.Contains("a/b/c/getting-started.md") && d.Message.Contains("deep-linking") && + d.Message.Contains("folder") + ); } [Fact] public async Task ValidationEmitsHintForNestedPathVirtualFiles() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: guides/api/overview.md @@ -227,17 +233,19 @@ public async Task ValidationEmitsHintForNestedPathVirtualFiles() context.Collector.Hints.Should().BeGreaterThan(0); var diagnostics = context.Diagnostics; - diagnostics.Should().Contain(d => - d.Severity == Severity.Hint && - d.Message.Contains("guides/api/overview.md") && - d.Message.Contains("Virtual files are primarily intended to group sibling files together")); + diagnostics.Should().Contain( + d => + d.Severity == Severity.Hint && d.Message.Contains("guides/api/overview.md") && + d.Message.Contains("Virtual files are primarily intended to group sibling files together") + ); } [Fact] public async Task ValidationDoesNotEmitHintForSimpleVirtualFiles() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: guide.md @@ -264,7 +272,8 @@ public async Task ValidationDoesNotEmitHintForSimpleVirtualFiles() public async Task BuildNavigationLookupsDoesNotThrowWhenTocReferencesMissingFile() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: missing.md @@ -287,16 +296,15 @@ public async Task BuildNavigationLookupsDoesNotThrowWhenTocReferencesMissingFile context.Collector.Errors.Should().BeGreaterThan(0); var diagnostics = context.Diagnostics; - diagnostics.Should().Contain(d => - d.Message.Contains("missing.md") && - d.Message.Contains("does not exist")); + diagnostics.Should().Contain(d => d.Message.Contains("missing.md") && d.Message.Contains("does not exist")); } [Fact] public async Task ValidationDoesNotEmitHintForFilesWithoutChildren() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: a/b/c/getting-started.md diff --git a/tests/Navigation.Tests/Rendering/NavigationRenderModelTests.cs b/tests/Navigation.Tests/Rendering/NavigationRenderModelTests.cs index ea460be733..70aa861f89 100644 --- a/tests/Navigation.Tests/Rendering/NavigationRenderModelTests.cs +++ b/tests/Navigation.Tests/Rendering/NavigationRenderModelTests.cs @@ -18,7 +18,8 @@ public class NavigationRenderModelTests(ITestOutputHelper output) : Documentatio public void EquivalentTrees_ProduceSameContentHash() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: index.md @@ -38,19 +39,23 @@ public void EquivalentTrees_ProduceSameContentHash() public void DifferentPages_ProduceDifferentContentHashes() { // language=yaml - var first = CreateRenderModel(""" + var first = CreateRenderModel( + """ project: 'test-project' toc: - file: index.md - file: overview.md - """); + """ + ); // language=yaml - var second = CreateRenderModel(""" + var second = CreateRenderModel( + """ project: 'test-project' toc: - file: index.md - file: reference.md - """); + """ + ); first.ContentHash.Should().NotBe(second.ContentHash); } @@ -59,21 +64,25 @@ public void DifferentPages_ProduceDifferentContentHashes() public void ReorderedSiblings_ProduceDifferentContentHashes() { // language=yaml - var first = CreateRenderModel(""" + var first = CreateRenderModel( + """ project: 'test-project' toc: - file: index.md - file: alpha.md - file: beta.md - """); + """ + ); // language=yaml - var second = CreateRenderModel(""" + var second = CreateRenderModel( + """ project: 'test-project' toc: - file: index.md - file: beta.md - file: alpha.md - """); + """ + ); first.ContentHash.Should().NotBe(second.ContentHash); } @@ -82,21 +91,25 @@ public void ReorderedSiblings_ProduceDifferentContentHashes() public void HiddenItems_AreExcludedFromTheTree_AndChangeTheContentHash() { // language=yaml - var visible = CreateRenderModel(""" + var visible = CreateRenderModel( + """ project: 'test-project' toc: - file: index.md - file: guide.md - file: secret.md - """); + """ + ); // language=yaml - var hidden = CreateRenderModel(""" + var hidden = CreateRenderModel( + """ project: 'test-project' toc: - file: index.md - file: guide.md - hidden: secret.md - """); + """ + ); visible.Tree.Should().Contain(n => n.Url == "/secret"); hidden.Tree.Should().NotContain(n => n.Url == "/secret"); @@ -107,7 +120,8 @@ public void HiddenItems_AreExcludedFromTheTree_AndChangeTheContentHash() public void PrimaryNav_OmitsIndexRow_AndChangesTheContentHash() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: index.md @@ -127,7 +141,8 @@ public void PrimaryNav_OmitsIndexRow_AndChangesTheContentHash() public void Nodes_CarryToggleStateAndNavigationItems() { // language=yaml - var model = CreateRenderModel(""" + var model = CreateRenderModel( + """ project: 'test-project' toc: - file: index.md @@ -135,7 +150,8 @@ public void Nodes_CarryToggleStateAndNavigationItems() children: - file: index.md - file: install.md - """); + """ + ); var node = model.Tree.Should().ContainSingle(n => n.Kind == NavigationRenderNodeKind.Node).Subject; node.Url.Should().Be("/setup"); @@ -152,7 +168,8 @@ public void Nodes_CarryToggleStateAndNavigationItems() public async Task IslandNode_ProjectsAsIslandKind_WithNoChildren() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: index.md @@ -165,17 +182,24 @@ public async Task IslandNode_ProjectsAsIslandKind_WithNoChildren() fileSystem.AddFile("/docs/index.md", new MockFileData("# Root")); fileSystem.AddFile("/docs/reference/index.md", new MockFileData("# Reference")); fileSystem.AddFile("/docs/reference/page.md", new MockFileData("# Page")); - fileSystem.AddFile("/docs/reference/toc.yml", new MockFileData( - """ + fileSystem.AddFile( + "/docs/reference/toc.yml", + new MockFileData(""" toc: - file: index.md - file: page.md - """)); + """) + ); var context = CreateContext(fileSystem); var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); - var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance, crossLinkResolver: TestCrossLinkResolver.Instance); + var navigation = new DocumentationSetNavigation( + docSet, + context, + TestDocumentationFileFactory.Instance, + crossLinkResolver: TestCrossLinkResolver.Instance + ); await context.Collector.StopAsync(TestContext.Current.CancellationToken); var model = NavigationRenderModel.Create( @@ -183,7 +207,8 @@ public async Task IslandNode_ProjectsAsIslandKind_WithNoChildren() topLevelItems: navigation.NavigationItems.OfType>().ToList(), isUsingNavigationDropdown: false, isPrimaryNavEnabled: false, - isGlobalAssemblyBuild: false); + isGlobalAssemblyBuild: false + ); // The island node appears in the tree var islandNode = model.Tree.Should().ContainSingle(n => n.Kind == NavigationRenderNodeKind.Island).Subject; @@ -196,7 +221,8 @@ public async Task IslandNode_ProjectsAsIslandKind_WithNoChildren() public async Task Create_BuildsBackLinkStack_RootFirst() { // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: index.md @@ -210,24 +236,35 @@ public async Task Create_BuildsBackLinkStack_RootFirst() fileSystem.AddFile("/docs/security/index.md", new MockFileData("# Security")); fileSystem.AddFile("/docs/security/rules/index.md", new MockFileData("# Rules")); fileSystem.AddFile("/docs/security/rules/page.md", new MockFileData("# Page")); - fileSystem.AddFile("/docs/security/toc.yml", new MockFileData( - """ + fileSystem.AddFile( + "/docs/security/toc.yml", + new MockFileData( + """ toc: - file: index.md - toc: rules island: true - """)); - fileSystem.AddFile("/docs/security/rules/toc.yml", new MockFileData( """ + ) + ); + fileSystem.AddFile( + "/docs/security/rules/toc.yml", + new MockFileData(""" toc: - file: index.md - file: page.md - """)); + """) + ); var context = CreateContext(fileSystem); var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); - var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance, crossLinkResolver: TestCrossLinkResolver.Instance); + var navigation = new DocumentationSetNavigation( + docSet, + context, + TestDocumentationFileFactory.Instance, + crossLinkResolver: TestCrossLinkResolver.Instance + ); await context.Collector.StopAsync(TestContext.Current.CancellationToken); var security = (TableOfContentsNavigation)navigation.NavigationItems.ElementAt(0); @@ -239,7 +276,8 @@ public async Task Create_BuildsBackLinkStack_RootFirst() topLevelItems: [navigation], isUsingNavigationDropdown: false, isPrimaryNavEnabled: false, - isGlobalAssemblyBuild: false); + isGlobalAssemblyBuild: false + ); // Back links: root-first → docset root, then security (enclosing island, also the immediate parent). // Stack: docset root (/), then security (/security) — total 2 entries. @@ -261,7 +299,8 @@ public async Task Create_ContentHash_DiffersByTreeStructure() { // Islands with different page trees must produce different hashes even when back links are identical. // language=yaml - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: index.md @@ -274,17 +313,24 @@ public async Task Create_ContentHash_DiffersByTreeStructure() fileSystem.AddFile("/docs/index.md", new MockFileData("# Root")); fileSystem.AddFile("/docs/reference/index.md", new MockFileData("# Reference")); fileSystem.AddFile("/docs/reference/page-a.md", new MockFileData("# Page A")); - fileSystem.AddFile("/docs/reference/toc.yml", new MockFileData( - """ + fileSystem.AddFile( + "/docs/reference/toc.yml", + new MockFileData(""" toc: - file: index.md - file: page-a.md - """)); + """) + ); var context = CreateContext(fileSystem); var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); - var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance, crossLinkResolver: TestCrossLinkResolver.Instance); + var navigation = new DocumentationSetNavigation( + docSet, + context, + TestDocumentationFileFactory.Instance, + crossLinkResolver: TestCrossLinkResolver.Instance + ); await context.Collector.StopAsync(TestContext.Current.CancellationToken); var reference = (TableOfContentsNavigation)navigation.NavigationItems.ElementAt(0); @@ -293,11 +339,13 @@ public async Task Create_ContentHash_DiffersByTreeStructure() topLevelItems: [], isUsingNavigationDropdown: false, isPrimaryNavEnabled: false, - isGlobalAssemblyBuild: false); + isGlobalAssemblyBuild: false + ); // Second navigation: same structure but a different page name in the island tree // language=yaml - var yaml2 = """ + var yaml2 = + """ project: 'test-project' toc: - file: index.md @@ -308,17 +356,24 @@ public async Task Create_ContentHash_DiffersByTreeStructure() fileSystem2.AddDirectory("/docs"); fileSystem2.AddFile("/docs/index.md", new MockFileData("# Root")); fileSystem2.AddFile("/docs/reference/index.md", new MockFileData("# Reference")); - fileSystem2.AddFile("/docs/reference/page-b.md", new MockFileData("# Page B")); // ← different page - fileSystem2.AddFile("/docs/reference/toc.yml", new MockFileData( - """ + fileSystem2.AddFile("/docs/reference/page-b.md", new MockFileData("# Page B")); // ← different page + fileSystem2.AddFile( + "/docs/reference/toc.yml", + new MockFileData(""" toc: - file: index.md - file: page-b.md - """)); + """) + ); var context2 = CreateContext(fileSystem2); var docSet2 = DocumentationSetFile.LoadAndResolve(context2.Collector, yaml2, fileSystem2.NewDirInfo("docs")); _ = context2.Collector.StartAsync(TestContext.Current.CancellationToken); - var navigation2 = new DocumentationSetNavigation(docSet2, context2, TestDocumentationFileFactory.Instance, crossLinkResolver: TestCrossLinkResolver.Instance); + var navigation2 = new DocumentationSetNavigation( + docSet2, + context2, + TestDocumentationFileFactory.Instance, + crossLinkResolver: TestCrossLinkResolver.Instance + ); await context2.Collector.StopAsync(TestContext.Current.CancellationToken); var reference2 = (TableOfContentsNavigation)navigation2.NavigationItems.ElementAt(0); @@ -327,7 +382,8 @@ public async Task Create_ContentHash_DiffersByTreeStructure() topLevelItems: [], isUsingNavigationDropdown: false, isPrimaryNavEnabled: false, - isGlobalAssemblyBuild: false); + isGlobalAssemblyBuild: false + ); // Different tree content (page-a vs page-b) → different content hash model1.ContentHash.Should().NotBe(model2.ContentHash, "different tree pages produce different content hashes"); @@ -341,7 +397,8 @@ public void Create_TopLevelIsland_HasDropdownAndNoBackLinks() { // A top-level section (Parent is nav root, grandparent is null) has the dropdown // as its only mechanism — no back-link trail is generated. - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: index.md @@ -365,7 +422,8 @@ public void Create_TopLevelIsland_HasDropdownAndNoBackLinks() topLevelItems: [navigation], isUsingNavigationDropdown: true, isPrimaryNavEnabled: true, - isGlobalAssemblyBuild: true); + isGlobalAssemblyBuild: true + ); // No back-links: the only ancestor would be the nav root, which the dropdown replaces model.BackLinks.Should().BeEmpty("top-level sections rely on the dropdown, not back-links"); @@ -381,7 +439,8 @@ public async Task Create_NestedIsland_KeepsTopLevelBackLink_AlongsideDropdown() // because re-selecting the active dropdown item is a poor UX substitute for a direct link. // We simulate this with a 3-level tree: docset root → elasticsearch (island) → clients (island). // docset root plays the nav-root role (Parent=null), elasticsearch plays top-level section. - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: index.md @@ -394,29 +453,46 @@ public async Task Create_NestedIsland_KeepsTopLevelBackLink_AlongsideDropdown() fileSystem.AddFile("/docs/index.md", new MockFileData("# Root")); fileSystem.AddFile("/docs/elasticsearch/index.md", new MockFileData("# Elasticsearch")); fileSystem.AddFile("/docs/elasticsearch/clients/index.md", new MockFileData("# Clients")); - fileSystem.AddFile("/docs/elasticsearch/toc.yml", new MockFileData( - """ + fileSystem.AddFile( + "/docs/elasticsearch/toc.yml", + new MockFileData( + """ toc: - file: index.md - toc: clients island: true - """)); - fileSystem.AddFile("/docs/elasticsearch/clients/toc.yml", new MockFileData( """ + ) + ); + fileSystem.AddFile( + "/docs/elasticsearch/clients/toc.yml", + new MockFileData(""" toc: - file: index.md - """)); + """) + ); var context = CreateContext(fileSystem); var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); - var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance, crossLinkResolver: TestCrossLinkResolver.Instance); + var navigation = new DocumentationSetNavigation( + docSet, + context, + TestDocumentationFileFactory.Instance, + crossLinkResolver: TestCrossLinkResolver.Instance + ); await context.Collector.StopAsync(TestContext.Current.CancellationToken); - var elasticsearch = navigation.NavigationItems.ElementAt(0) - .Should().BeOfType>().Subject; - var clients = elasticsearch.NavigationItems.ElementAt(0) - .Should().BeOfType>().Subject; + var elasticsearch = navigation.NavigationItems + .ElementAt(0) + .Should() + .BeOfType>() + .Subject; + var clients = elasticsearch.NavigationItems + .ElementAt(0) + .Should() + .BeOfType>() + .Subject; // elasticsearch is the "top-level section" (Parent=navigation/nav-root, island=true) elasticsearch.RendersAsIsland().Should().BeTrue(); @@ -425,27 +501,30 @@ public async Task Create_NestedIsland_KeepsTopLevelBackLink_AlongsideDropdown() var model = NavigationRenderModel.Create( tree: clients, - topLevelItems: [elasticsearch], // elasticsearch = the "top-level section" in the dropdown + topLevelItems: [elasticsearch], // elasticsearch = the "top-level section" in the dropdown + isUsingNavigationDropdown: true, isPrimaryNavEnabled: true, - isGlobalAssemblyBuild: false); + isGlobalAssemblyBuild: false + ); // ← elasticsearch appears even though the dropdown also shows elasticsearch as active. // The nav root (navigation, Parent=null) is suppressed because the dropdown covers it. - model.BackLinks.Should().ContainSingle(b => b.Url == elasticsearch.Url, - "← elasticsearch stays even though the dropdown names it as the active section"); + model.BackLinks + .Should() + .ContainSingle(b => b.Url == elasticsearch.Url, "← elasticsearch stays even though the dropdown names it as the active section"); // Dropdown correctly identifies elasticsearch as the current top-level section model.CurrentTopLevelUrl.Should().Be(elasticsearch.Url); // Nav root must NOT appear in back-links (dropdown suppresses it) - model.BackLinks.Should().NotContain(b => b.Url == navigation.Url, - "nav root is represented by the dropdown, not a back-link"); + model.BackLinks.Should().NotContain(b => b.Url == navigation.Url, "nav root is represented by the dropdown, not a back-link"); } [Fact] public async Task Create_WithoutDropdown_KeepsFullBackLinkTrail() { // Isolated build without primary nav: back-links include the navigation root - var yaml = """ + var yaml = + """ project: 'test-project' toc: - file: index.md @@ -462,22 +541,26 @@ public async Task Create_WithoutDropdown_KeepsFullBackLinkTrail() var context = CreateContext(fileSystem); var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); - var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance, crossLinkResolver: TestCrossLinkResolver.Instance); + var navigation = new DocumentationSetNavigation( + docSet, + context, + TestDocumentationFileFactory.Instance, + crossLinkResolver: TestCrossLinkResolver.Instance + ); await context.Collector.StopAsync(TestContext.Current.CancellationToken); - var clients = navigation.NavigationItems.ElementAt(0) - .Should().BeOfType>().Subject; + var clients = navigation.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; var model = NavigationRenderModel.Create( tree: clients, topLevelItems: [navigation], isUsingNavigationDropdown: false, isPrimaryNavEnabled: false, - isGlobalAssemblyBuild: false); + isGlobalAssemblyBuild: false + ); // Without dropdown the nav root IS included in back-links - model.BackLinks.Should().ContainSingle(b => b.Url == navigation.Url, - "without dropdown the nav root appears as a back-link"); + model.BackLinks.Should().ContainSingle(b => b.Url == navigation.Url, "without dropdown the nav root appears as a back-link"); model.IsUsingNavigationDropdown.Should().BeFalse(); } @@ -493,6 +576,7 @@ private NavigationRenderModel CreateRenderModel(string yaml, bool isPrimaryNavEn topLevelItems: navigation.NavigationItems.OfType>().ToList(), isUsingNavigationDropdown: false, isPrimaryNavEnabled: isPrimaryNavEnabled, - isGlobalAssemblyBuild: false); + isGlobalAssemblyBuild: false + ); } } diff --git a/tests/Navigation.Tests/Rendering/SecondaryNavRenderingTests.cs b/tests/Navigation.Tests/Rendering/SecondaryNavRenderingTests.cs index b224852348..43e4dbbdf8 100644 --- a/tests/Navigation.Tests/Rendering/SecondaryNavRenderingTests.cs +++ b/tests/Navigation.Tests/Rendering/SecondaryNavRenderingTests.cs @@ -24,14 +24,13 @@ public class SecondaryNavRenderingTests(ITestOutputHelper output) : Documentatio private static readonly TopNavRenderModel TopNav = new([ new TopNavLinkItem("Reference", "/docs/reference/", false, SectionId: ReferenceSectionId), new TopNavLinkItem("APIs", "https://www.elastic.co/docs/api/", true), - new TopNavDropdownItem("Products", [ - new TopNavGroup("Stack products", [ - new TopNavLinkItem("Elasticsearch", "/docs/products/elasticsearch/", false) - ]), - new TopNavGroup(null, [ - new TopNavLinkItem("All products", "/docs/products/", false) - ]) - ]) + new TopNavDropdownItem( + "Products", + [ + new TopNavGroup("Stack products", [new TopNavLinkItem("Elasticsearch", "/docs/products/elasticsearch/", false)]), + new TopNavGroup(null, [new TopNavLinkItem("All products", "/docs/products/", false)]) + ] + ) ]); [Fact] @@ -114,8 +113,7 @@ public async Task TheItemCoveringTheCurrentPageIsMarkedActive() var product = await Render(TopNav, currentUrl: "/docs/products/elasticsearch/index"); var productListItem = product.Split(" li.Contains("Products")); // "hover:text-blue-elastic" present means the inactive CSS variant is applied, not the active one. - productListItem.Should().Contain("hover:text-blue-elastic") - .And.NotContain("relative text-blue-elastic\""); + productListItem.Should().Contain("hover:text-blue-elastic").And.NotContain("relative text-blue-elastic\""); } [Fact] @@ -130,7 +128,8 @@ public async Task UnrelatedPagesLeaveEveryItemInactive() private async Task Render( TopNavRenderModel? topNav, string currentUrl, - IRootNavigationItem? root = null) + IRootNavigationItem? root = null + ) { var fileSystem = new MockFileSystem(); fileSystem.AddDirectory("/docs"); @@ -165,7 +164,8 @@ private async Task Render( /// The secondary nav only reads off the current page. private sealed record StubNavigationItem( string Url, - IRootNavigationItem? Root = null) : INavigationItem + IRootNavigationItem? Root = null + ) : INavigationItem { public string NavigationTitle => "stub"; public IRootNavigationItem NavigationRoot => Root ?? null!; @@ -178,8 +178,9 @@ private sealed record StubNavigationItem( /// Stands in for SiteNavigation as the outermost parent so /// resolves correctly. /// - private sealed class MockSiteNavigationRoot(TopNavRenderModel? topNav) - : INodeNavigationItem, ISiteNavigationRoot + private sealed class MockSiteNavigationRoot( + TopNavRenderModel? topNav + ) : INodeNavigationItem, ISiteNavigationRoot { public TopNavRenderModel? TopNav { get; } = topNav; public string Id => "mock-site"; @@ -197,8 +198,7 @@ private sealed class MockSiteNavigationRoot(TopNavRenderModel? topNav) /// Minimal root stub — _SecondaryNav.cshtml reads /// and compares its Id against each tab's SectionId(s). /// - private sealed class MockSectionRoot(string id) - : IRootNavigationItem + private sealed class MockSectionRoot(string id) : IRootNavigationItem { public string Id => id; public Uri Identifier => new($"section://{id}"); diff --git a/tests/Navigation.Tests/TestDocumentationSetContext.cs b/tests/Navigation.Tests/TestDocumentationSetContext.cs index b7c299cd00..250a32be68 100644 --- a/tests/Navigation.Tests/TestDocumentationSetContext.cs +++ b/tests/Navigation.Tests/TestDocumentationSetContext.cs @@ -29,8 +29,7 @@ public void Write(Diagnostic diagnostic) } } -public class TestDiagnosticsCollector(ITestOutputHelper output) - : DiagnosticsCollector([new TestDiagnosticsOutput(output)]) +public class TestDiagnosticsCollector(ITestOutputHelper output) : DiagnosticsCollector([new TestDiagnosticsOutput(output)]) { private readonly List _diagnostics = []; @@ -69,12 +68,12 @@ public bool TryResolve(Action errorEmitter, Uri crossLinkUri, [NotNullWh public bool IsDeclaredCrossLinkScheme(string scheme) => true; private TestCrossLinkResolver() { } - } public class TestDocumentationSetContext : IDocumentationSetContext { - public TestDocumentationSetContext(IFileSystem fileSystem, + public TestDocumentationSetContext( + IFileSystem fileSystem, IDirectoryInfo sourceDirectory, IDirectoryInfo outputDirectory, IFileInfo configPath, @@ -83,19 +82,19 @@ public TestDocumentationSetContext(IFileSystem fileSystem, TestDiagnosticsCollector? collector = null ) { - ReadFileSystem = DocumentationFileSystem.Resolve(sourceDirectory, new DocumentationScopeOptions { Inner = fileSystem, ConfigurationFile = configPath.FullName }); + ReadFileSystem = + DocumentationFileSystem.Resolve( + sourceDirectory, + new DocumentationScopeOptions { Inner = fileSystem, ConfigurationFile = configPath.FullName } + ); WriteFileSystem = new DocumentationWriteFileSystem(sourceDirectory, outputDirectory, fileSystem); DocumentationSourceDirectory = sourceDirectory; OutputDirectory = outputDirectory; ConfigurationPath = configPath; Collector = collector ?? new TestDiagnosticsCollector(output); - Git = repository is null ? GitCheckoutInformation.Unavailable : new GitCheckoutInformation - { - Branch = "main", - Remote = $"elastic/{repository}", - Ref = "main", - RepositoryName = repository - }; + Git = repository is null + ? GitCheckoutInformation.Unavailable + : new GitCheckoutInformation { Branch = "main", Remote = $"elastic/{repository}", Ref = "main", RepositoryName = repository }; // Start the diagnostics collector to process messages _ = Collector.StartAsync(Cancel.None); @@ -138,9 +137,7 @@ public TestDocumentationFile TryCreateDocumentationFile(IFileInfo path, IFileSys { // Extract the title from the file name (without extension) var fileName = path.Name; - var title = fileName.EndsWith(".md", StringComparison.OrdinalIgnoreCase) - ? fileName[..^3] - : fileName; + var title = fileName.EndsWith(".md", StringComparison.OrdinalIgnoreCase) ? fileName[..^3] : fileName; return new TestDocumentationFile(title); } } @@ -156,9 +153,7 @@ public class CodexTestDocumentationFileFactory : IDocumentationFileFactory