diff --git a/docs/_snippets/applies_to-version.md b/docs/_snippets/applies_to-version.md index f98f758d0..ef6ed62be 100644 --- a/docs/_snippets/applies_to-version.md +++ b/docs/_snippets/applies_to-version.md @@ -1,10 +1,65 @@ `applies_to` accepts the following version formats: -* `Major.Minor` -* `Major.Minor.Patch` +### Version specifiers -Regardless of the version format used in the source file, the version number is always rendered in the `Major.Minor.Patch` format. +You can use version specifiers to precisely control how versions are interpreted: + +| Specifier | Syntax | Description | Example | +|-----------|--------|-------------|---------| +| Greater than or equal (default) | `x.x` `x.x+` `x.x.x` `x.x.x+` | Feature available from this version onwards | `ga 9.2+` or `ga 9.2` | +| Range (inclusive) | `x.x-y.y` `x.x.x-y.y.y` | Feature available only in this version range | `beta 9.0-9.1` | +| Exact version | `=x.x` `=x.x.x` | Feature available only in this specific version | `preview =9.0` | + +Regardless of the version format used in the source file, the version number is always rendered in the `Major.Minor` format in badges. + +:::{note} +The `+` suffix is optional for greater-than-or-equal syntax. Both `ga 9.2` and `ga 9.2+` have the same meaning. +::: + +### Examples + +```yaml +# Greater than or equal (feature available from 9.2 onwards) +stack: ga 9.2 +stack: ga 9.2+ + +# Range (feature was in beta from 9.0 to 9.1, then became GA) +stack: ga 9.2+, beta 9.0-9.1 + +# Exact version (feature was in preview only in 9.0) +stack: ga 9.1+, preview =9.0 +``` + +### Implicit version inference for multiple lifecycles {#implicit-version-inference} + +When you specify multiple lifecycles with simple versions (without explicit specifiers), the system automatically infers the version ranges: + +**Input:** +```yaml +stack: preview 9.0, alpha 9.1, beta 9.2, ga 9.4 +``` + +**Interpreted as:** +```yaml +stack: preview =9.0, alpha =9.1, beta 9.2-9.3, ga 9.4+ +``` + +The inference rules are: +1. **Consecutive versions**: If a lifecycle is immediately followed by another in the next minor version, it's treated as an **exact version** (`=x.x`). +2. **Non-consecutive versions**: If there's a gap between one lifecycle's version and the next lifecycle's version, it becomes a **range** from the start version to one version before the next lifecycle. +3. **Last lifecycle**: The highest versioned lifecycle is always treated as **greater-than-or-equal** (`x.x+`). + +This makes it easy to document features that evolve through multiple lifecycle stages. For example, a feature that goes through preview → beta → GA can be written simply as: + +```yaml +stack: preview 9.0, beta 9.1, ga 9.3 +``` + +Which is automatically interpreted as: +```yaml +stack: preview =9.0, beta 9.1-9.2, ga 9.3+ +``` :::{note} -**Automatic Version Sorting**: When you specify multiple versions for the same product, the build system automatically sorts them in descending order (highest version first) regardless of the order you write them in the source file. For example, `stack: ga 8.18.6, ga 9.1.2, ga 8.19.2, ga 9.0.6` will be displayed as `stack: ga 9.1.2, ga 9.0.6, ga 8.19.2, ga 8.18.6`. Items without versions (like `ga` without a version or `all`) are sorted last. +**Automatic Version Sorting**: When you specify multiple versions for the same product, the build system automatically sorts them in descending order (highest version first) regardless of the order you write them in the source file. For example, `stack: ga 9.1, beta 9.0, preview 8.18` will be displayed with the highest priority lifecycle and version first. Items without versions are sorted last. ::: \ No newline at end of file diff --git a/docs/syntax/_snippets/inline-level-applies-examples.md b/docs/syntax/_snippets/inline-level-applies-examples.md index 58f38476c..1fedf4759 100644 --- a/docs/syntax/_snippets/inline-level-applies-examples.md +++ b/docs/syntax/_snippets/inline-level-applies-examples.md @@ -55,7 +55,7 @@ This example shows how to use directly a key from the second level of the `appli ::::{tab-item} Output - {applies_to}`serverless: ga` {applies_to}`stack: ga 9.1.0` -- {applies_to}`edot_python: preview 1.7.0, ga 1.8.0` {applies_to}`apm_agent_java: beta 1.0.0, ga 1.2.0` +- {applies_to}`edot_python: preview =1.7.0, ga 1.8.0` {applies_to}`apm_agent_java: beta =1.0.0, ga 1.2.0` - {applies_to}`stack: ga 9.0` {applies_to}`eck: ga 3.0` :::: @@ -63,7 +63,7 @@ This example shows how to use directly a key from the second level of the `appli ::::{tab-item} Markdown ```markdown - {applies_to}`serverless: ga` {applies_to}`stack: ga 9.1.0` -- {applies_to}`edot_python: preview 1.7.0, ga 1.8.0` {applies_to}`apm_agent_java: beta 1.0.0, ga 1.2.0` +- {applies_to}`edot_python: preview =1.7.0, ga 1.8.0` {applies_to}`apm_agent_java: beta =1.0.0, ga 1.2.0` - {applies_to}`stack: ga 9.0` {applies_to}`eck: ga 3.0` ``` :::: diff --git a/docs/syntax/_snippets/multiple-lifecycle-states.md b/docs/syntax/_snippets/multiple-lifecycle-states.md index bb0bedf40..8c9dcd069 100644 --- a/docs/syntax/_snippets/multiple-lifecycle-states.md +++ b/docs/syntax/_snippets/multiple-lifecycle-states.md @@ -1,12 +1,35 @@ -`applies_to` keys accept comma-separated values to specify lifecycle states for multiple product versions. For example: +`applies_to` keys accept comma-separated values to specify lifecycle states for multiple product versions. -* A feature is added in 9.1 as tech preview and becomes GA in 9.4: +When you specify multiple lifecycles with simple versions, the system automatically infers whether each version represents an exact version, a range, or an open-ended range. Refer to [Implicit version inference](/_snippets/applies_to-version.md#implicit-version-inference) for details. + +### Examples + +* A feature is added in 9.0 as tech preview and becomes GA in 9.1: + + ```yml + applies_to: + stack: preview 9.0, ga 9.1 + ``` + + The preview is automatically interpreted as `=9.0` (exact), and GA as `9.1+` (open-ended). + +* A feature goes through multiple stages before becoming GA: + + ```yml + applies_to: + stack: preview 9.0, beta 9.1, ga 9.3 + ``` + + Interpreted as: `preview =9.0`, `beta 9.1-9.2`, `ga 9.3+` + +* A feature is unavailable for one version, beta for another, preview for a range, then GA: ```yml applies_to: - stack: preview 9.1, ga 9.4 + stack: unavailable 9.0, beta 9.1, preview 9.2, ga 9.4 ``` + Interpreted as: `unavailable =9.0`, `beta =9.1`, `preview 9.2-9.3`, `ga 9.4+` * A feature is deprecated in ECE 4.0 and is removed in 4.8. At the same time, it has already been removed in {{ech}}: @@ -15,4 +38,17 @@ deployment: ece: deprecated 4.0, removed 4.8 ess: removed + ``` + + The deprecated lifecycle is interpreted as `4.0-4.7` (range until removal). + +* Use explicit specifiers when you need precise control: + + ```yml + applies_to: + # Explicit exact version + stack: preview =9.0, ga 9.1+ + + # Explicit range + stack: beta 9.0-9.1, ga 9.2+ ``` \ No newline at end of file diff --git a/docs/syntax/_snippets/versioned-lifecycle.md b/docs/syntax/_snippets/versioned-lifecycle.md index ae6af6fee..cfe372e69 100644 --- a/docs/syntax/_snippets/versioned-lifecycle.md +++ b/docs/syntax/_snippets/versioned-lifecycle.md @@ -7,6 +7,8 @@ --- ``` + This means the feature is available from version 9.3 onwards (equivalent to `ga 9.3+`). + * When a change is introduced as preview or beta, use `preview` or `beta` as value for the corresponding key within the `applies_to`: ``` @@ -16,6 +18,28 @@ --- ``` +* When a feature is available only in a specific version range, use the range syntax: + + ``` + --- + applies_to: + stack: beta 9.0-9.1, ga 9.2 + --- + ``` + + This means the feature was in beta from 9.0 to 9.1, then became GA in 9.2+. + +* When a feature was in a specific lifecycle for exactly one version, use the exact syntax: + + ``` + --- + applies_to: + stack: preview =9.0, ga 9.1 + --- + ``` + + This means the feature was in preview only in 9.0, then became GA in 9.1+. + * When a change introduces a deprecation, use `deprecated` as value for the corresponding key within the `applies_to`: ``` @@ -33,4 +57,6 @@ applies_to: stack: deprecated 9.1, removed 9.4 --- - ``` \ No newline at end of file + ``` + + With the implicit version inference, this is interpreted as `deprecated 9.1-9.3, removed 9.4+`. \ No newline at end of file diff --git a/docs/syntax/applies.md b/docs/syntax/applies.md index c6621e483..59c33d720 100644 --- a/docs/syntax/applies.md +++ b/docs/syntax/applies.md @@ -29,6 +29,41 @@ Where: - The lifecycle is mandatory. - The version is optional. +### Version Syntax + +Versions can be specified using several formats to indicate different applicability scenarios: + +| Description | Syntax | Example | Badge Display | +|:------------|:-------|:--------|:--------------| +| **Greater than or equal to** (default) | `x.x+` `x.x` `x.x.x+` `x.x.x` | `ga 9.1` or `ga 9.1+` | `9.1+` | +| **Range** (inclusive) | `x.x-y.y` `x.x.x-y.y.y` | `preview 9.0-9.2` | `9.0-9.2` or `9.0+`* | +| **Exact version** | `=x.x` `=x.x.x` | `beta =9.1` | `9.1` | + +\* Range display depends on release status of the second version. + +**Important notes:** + +- Versions are always displayed as **Major.Minor** (e.g., `9.1`) in badges, regardless of whether you specify patch versions in the source. +- Each version statement corresponds to the **latest patch** of the specified minor version (e.g., `9.1` represents 9.1.0, 9.1.1, 9.1.6, etc.). +- When critical patch-level differences exist, use plain text descriptions alongside the badge rather than specifying patch versions. + +### Version Validation Rules + +The build process enforces the following validation rules: + +- **One version per lifecycle**: Each lifecycle (GA, Preview, Beta, etc.) can only have one version declaration. + - ✅ `stack: ga 9.2+, beta 9.0-9.1` + - ❌ `stack: ga 9.2, ga 9.3` +- **One "greater than" per key**: Only one lifecycle per product key can use the `+` (greater than or equal to) syntax. + - ✅ `stack: ga 9.2+, beta 9.0-9.1` + - ❌ `stack: ga 9.2+, beta 9.0+` +- **Valid range order**: In ranges, the first version must be less than or equal to the second version. + - ✅ `stack: preview 9.0-9.2` + - ❌ `stack: preview 9.2-9.0` +- **No version overlaps**: Versions for the same key cannot overlap (ranges are inclusive). + - ✅ `stack: ga 9.2+, beta 9.0-9.1` + - ❌ `stack: ga 9.2+, beta 9.0-9.2` + ### Page level Page level annotations are added in the YAML frontmatter, starting with the `applies_to` key and following the [key-value reference](#key-value-reference). For example: @@ -134,6 +169,22 @@ Use the following key-value reference to find the appropriate key and value for ## Examples +### Version Syntax Examples + +The following table demonstrates the various version syntax options and their rendered output: + +| Source Syntax | Description | Badge Display | Notes | +|:-------------|:------------|:--------------|:------| +| `stack: ga 9.1` | Greater than or equal to 9.1 | `Stack│9.1+` | Default behavior, equivalent to `9.1+` | +| `stack: ga 9.1+` | Explicit greater than or equal to | `Stack│9.1+` | Explicit `+` syntax | +| `stack: preview 9.0-9.2` | Range from 9.0 to 9.2 (inclusive) | `Stack│Preview 9.0-9.2` | Shows range if 9.2.0 is released | +| `stack: preview 9.0-9.3` | Range where end is unreleased | `Stack│Preview 9.0+` | Shows `+` if 9.3.0 is not released | +| `stack: beta =9.1` | Exact version 9.1 only | `Stack│Beta 9.1` | No `+` symbol for exact versions | +| `stack: ga 9.2+, beta 9.0-9.1` | Multiple lifecycles | `Stack│9.2+` | Only highest priority lifecycle shown | +| `stack: ga 9.3, beta 9.1+` | Unreleased GA with Preview | `Stack│Beta 9.1+` | Shows Beta when GA unreleased with 2+ lifecycles | +| `serverless: ga` | No version (base 99999) | `Serverless` | No version badge for unversioned products | +| `deployment:`
` ece: ga 9.0+` | Nested deployment syntax | `ECE│9.0+` | Deployment products shown separately | + ### Versioning examples Versioned products require a `version` tag to be used with the `lifecycle` tag: @@ -240,22 +291,46 @@ applies_to: ## Look and feel +### Version Syntax Demonstrations + +:::::{dropdown} New version syntax examples + +The following examples demonstrate the new version syntax capabilities: + +**Greater than or equal to:** +- {applies_to}`stack: ga 9.1` (implicit `+`) +- {applies_to}`stack: ga 9.1+` (explicit `+`) +- {applies_to}`stack: preview 9.0+` + +**Ranges:** +- {applies_to}`stack: preview 9.0-9.2` (range display when both released) +- {applies_to}`stack: beta 9.1-9.3` (converts to `+` if end unreleased) + +**Exact versions:** +- {applies_to}`stack: beta =9.1` (no `+` symbol) +- {applies_to}`stack: deprecated =9.0` + +**Multiple lifecycles:** +- {applies_to}`stack: ga 9.2+, beta 9.0-9.1` (shows highest priority) + +::::: + ### Block :::::{dropdown} Block examples ```{applies_to} -stack: preview 9.1 +stack: preview 9.1+ serverless: ga -apm_agent_dotnet: ga 1.0.0 -apm_agent_java: beta 1.0.0 -edot_dotnet: preview 1.0.0 +apm_agent_dotnet: ga 1.0+ +apm_agent_java: beta 1.0+ +edot_dotnet: preview 1.0+ edot_python: -edot_node: ga 1.0.0 -elasticsearch: preview 9.0.0 -security: removed 9.0.0 -observability: deprecated 9.0.0 +edot_node: ga 1.0+ +elasticsearch: preview 9.0+ +security: removed 9.0 +observability: deprecated 9.0+ ``` ::::: diff --git a/docs/testing/req.md b/docs/testing/req.md index 95907215f..21a16a3ba 100644 --- a/docs/testing/req.md +++ b/docs/testing/req.md @@ -8,24 +8,102 @@ mapped_pages: --- # Requirements +This page demonstrates various `applies_to` version syntax examples. + +## Version specifier examples + +### Greater than or equal (default) + +```{applies_to} +stack: ga 9.0 +``` + +This is equivalent to `ga 9.0+` — the feature is available from version 9.0 onwards. + +### Explicit range + +```{applies_to} +stack: beta 9.0-9.1, ga 9.2 +``` + +The feature was in beta from 9.0 to 9.1 (inclusive), then became GA in 9.2+. + +### Exact version + +```{applies_to} +stack: preview =9.0, ga 9.1 +``` + +The feature was in preview only in version 9.0 (exactly), then became GA in 9.1+. + +## Implicit version inference examples + +### Simple two-stage lifecycle + ```{applies_to} stack: preview 9.0, ga 9.1 ``` -1. Select **Create** to create a new policy, or select **Edit** {icon}`pencil` to open an existing policy. -1. Select **Create** to create a new policy, or select **Edit** {icon}`logo_vulnerability_management` to open an existing policy. +Interpreted as: `preview =9.0` (exact), `ga 9.1+` (open-ended). +### Multi-stage lifecycle with consecutive versions -{applies_to}`stack: preview 9.0` This tutorial is based on Elasticsearch 9.0. -This tutorial is based on Elasticsearch 9.0. This tutorial is based on Elasticsearch 9.0. -This tutorial is based on Elasticsearch 9.0. +```{applies_to} +stack: preview 9.0, beta 9.1, ga 9.2 +``` -what +Interpreted as: `preview =9.0`, `beta =9.1`, `ga 9.2+`. +### Multi-stage lifecycle with gaps -To follow this tutorial you will need to install the following components: +```{applies_to} +stack: unavailable 9.0, beta 9.1, preview 9.2, ga 9.4 +``` +Interpreted as: `unavailable =9.0`, `beta =9.1`, `preview 9.2-9.3` (range to fill the gap), `ga 9.4+`. +### Three stages with varying gaps + +```{applies_to} +stack: preview 8.0, beta 9.0, ga 9.2 +``` + +Interpreted as: `preview 8.0-8.19`, `beta 9.0-9.1`, `ga 9.2+`. + +## Inline examples + +{applies_to}`stack: preview 9.0` This feature is in preview in 9.0. + +{applies_to}`stack: beta 9.0-9.1` This feature was in beta from 9.0 to 9.1. + +{applies_to}`stack: ga 9.2+` This feature is generally available since 9.2. + +{applies_to}`stack: preview =9.0` This feature was in preview only in 9.0 (exact). + +## Deprecation and removal examples + +```{applies_to} +stack: deprecated 9.2, removed 9.5 +``` + +Interpreted as: `deprecated 9.2-9.4`, `removed 9.5+`. + +{applies_to}`stack: deprecated 9.0` This feature is deprecated starting in 9.0. + +{applies_to}`stack: removed 9.2` This feature was removed in 9.2. + +## Mixed deployment examples + +```{applies_to} +stack: ga 9.0 +deployment: + ece: ga 4.0 + eck: beta 3.0, ga 3.1 +``` + +## Additional content + +To follow this tutorial you will need to install the following components: - An installation of Elasticsearch, based on our hosted [Elastic Cloud](https://www.elastic.co/cloud) service (which includes a free trial period), or a self-hosted service that you run on your own computer. See the Install Elasticsearch section above for installation instructions. - A [Python](https://python.org) interpreter. Make sure it is a recent version, such as Python 3.8 or newer. @@ -36,5 +114,4 @@ The tutorial assumes that you have no previous knowledge of Elasticsearch or gen - The [Flask](https://flask.palletsprojects.com/) web framework for Python. - The command prompt or terminal application in your operating system. - {applies_to}`ece: removed` diff --git a/src/Elastic.ApiExplorer/Elasticsearch/OpenApiDocumentExporter.cs b/src/Elastic.ApiExplorer/Elasticsearch/OpenApiDocumentExporter.cs index b2c82a1ec..d94f5ca7c 100644 --- a/src/Elastic.ApiExplorer/Elasticsearch/OpenApiDocumentExporter.cs +++ b/src/Elastic.ApiExplorer/Elasticsearch/OpenApiDocumentExporter.cs @@ -209,7 +209,7 @@ private bool ShouldIncludeOperation(OpenApiOperation operation, string product) return true; // Could not parse version, safe to include // Get current version for the product - var versioningSystemId = product == "elasticsearch" + var versioningSystemId = product.Equals("elasticsearch", StringComparison.OrdinalIgnoreCase) ? VersioningSystemId.Stack : VersioningSystemId.Stack; // Both use Stack for now @@ -294,14 +294,14 @@ private static ProductLifecycle ParseLifecycle(string stateValue) /// /// Parses the version from "Added in X.Y.Z" pattern in the x-state string. /// - private static SemVersion? ParseVersion(string stateValue) + private static VersionSpec? ParseVersion(string stateValue) { var match = AddedInVersionRegex().Match(stateValue); if (!match.Success) return null; var versionString = match.Groups[1].Value; - return SemVersion.TryParse(versionString, out var version) ? version : null; + return VersionSpec.TryParse(versionString, out var version) ? version : null; } /// diff --git a/src/Elastic.Documentation/AppliesTo/Applicability.cs b/src/Elastic.Documentation/AppliesTo/Applicability.cs index b91fdb991..0c8ae9d4a 100644 --- a/src/Elastic.Documentation/AppliesTo/Applicability.cs +++ b/src/Elastic.Documentation/AppliesTo/Applicability.cs @@ -37,13 +37,88 @@ public static bool TryParse(string? value, IList<(Severity, string)> diagnostics if (applications.Count == 0) return false; + // Infer version semantics when multiple items have GreaterThanOrEqual versions + applications = InferVersionSemantics(applications); + // Sort by version in descending order (the highest version first) - // Items without versions (AllVersions.Instance) are sorted last + // Items without versions (AllVersionsSpec.Instance) are sorted last var sortedApplications = applications.OrderDescending().ToArray(); availability = new AppliesCollection(sortedApplications); return true; } + /// + /// Infers versioning semantics according to the following ruleset: + /// - The highest version keeps GreaterThanOrEqual (e.g., 9.4+) + /// - Lower versions become Exact if consecutive, or Range to fill gaps + /// - This rule only applies when all versions are at minor level (patch = 0). + /// + 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(); + + // If 0 or 1 GTE items, no inference needed + if (gteItems.Count <= 1) + return applications; + + // Only apply inference when all entries are on patch version 0 + if (gteItems.Any(a => a.Version!.Min.Patch != 0)) + 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(); + + if (sortedGteVersions.Count <= 1) + return applications; + + var versionMapping = new Dictionary(); + + for (var i = 0; i < sortedGteVersions.Count; i++) + { + var currentVersion = sortedGteVersions[i]; + + if (i == sortedGteVersions.Count - 1) + { + // Highest version keeps GreaterThanOrEqual + versionMapping[currentVersion] = VersionSpec.GreaterThanOrEqual(currentVersion); + } + else + { + 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) + versionMapping[currentVersion] = VersionSpec.Exact(currentVersion); + else + { + var rangeEnd = new SemVersion(nextVersion.Major, nextVersion.Minor - 1, 0); + versionMapping[currentVersion] = VersionSpec.Range(currentVersion, rangeEnd); + } + } + } + + // Apply the mapping to create updated applications + return applications.Select(a => + { + if (a.Version is null || a.Version == AllVersionsSpec.Instance || a is not { Version.Kind: VersionSpecKind.GreaterThanOrEqual }) + return a; + + if (versionMapping.TryGetValue(a.Version.Min, out var newSpec)) + return a with { Version = newSpec }; + + return a; + }).ToList(); + } + public virtual bool Equals(AppliesCollection? other) { if ((object)this == other) @@ -98,12 +173,12 @@ public override string ToString() public record Applicability : IComparable, IComparable { public ProductLifecycle Lifecycle { get; init; } - public SemVersion? Version { get; init; } + public VersionSpec? Version { get; init; } public static Applicability GenerallyAvailable { get; } = new() { Lifecycle = ProductLifecycle.GenerallyAvailable, - Version = AllVersions.Instance + Version = AllVersionsSpec.Instance }; @@ -126,8 +201,8 @@ public string GetLifeCycleName() => /// public int CompareTo(Applicability? other) { - var xIsNonVersioned = Version is null || ReferenceEquals(Version, AllVersions.Instance); - var yIsNonVersioned = other?.Version is null || ReferenceEquals(other.Version, AllVersions.Instance); + var xIsNonVersioned = Version is null || ReferenceEquals(Version, AllVersionsSpec.Instance); + var yIsNonVersioned = other?.Version is null || ReferenceEquals(other.Version, AllVersionsSpec.Instance); if (xIsNonVersioned && yIsNonVersioned) return 0; @@ -158,7 +233,7 @@ public override string ToString() _ => throw new ArgumentOutOfRangeException() }; _ = sb.Append(lifecycle); - if (Version is not null && Version != AllVersions.Instance) + if (Version is not null && Version != AllVersionsSpec.Instance) _ = sb.Append(' ').Append(Version); return sb.ToString(); } @@ -224,10 +299,10 @@ public static bool TryParse(string? value, IList<(Severity, string)> diagnostics ? null : tokens[1] switch { - null => AllVersions.Instance, - "all" => AllVersions.Instance, - "" => AllVersions.Instance, - var t => SemVersionConverter.TryParse(t, out var v) ? v : null + null => AllVersionsSpec.Instance, + "all" => AllVersionsSpec.Instance, + "" => AllVersionsSpec.Instance, + var t => VersionSpec.TryParse(t, out var v) ? v : null }; availability = new Applicability { Version = version, Lifecycle = lifecycle }; return true; diff --git a/src/Elastic.Documentation/AppliesTo/ApplicabilitySelector.cs b/src/Elastic.Documentation/AppliesTo/ApplicabilitySelector.cs index cb881fbf6..4dcc98494 100644 --- a/src/Elastic.Documentation/AppliesTo/ApplicabilitySelector.cs +++ b/src/Elastic.Documentation/AppliesTo/ApplicabilitySelector.cs @@ -30,25 +30,25 @@ public static Applicability GetPrimaryApplicability(IEnumerable a }; var availableApplicabilities = applicabilityList - .Where(a => a.Version is null || a.Version is AllVersions || a.Version <= currentVersion) + .Where(a => a.Version is null || a.Version is AllVersionsSpec || a.Version.Min <= currentVersion) .ToList(); if (availableApplicabilities.Count != 0) { return availableApplicabilities - .OrderByDescending(a => a.Version ?? new SemVersion(0, 0, 0)) + .OrderByDescending(a => a.Version?.Min ?? new SemVersion(0, 0, 0)) .ThenBy(a => lifecycleOrder.GetValueOrDefault(a.Lifecycle, 999)) .First(); } var futureApplicabilities = applicabilityList - .Where(a => a.Version is not null && a.Version is not AllVersions && a.Version > currentVersion) + .Where(a => a.Version is not null && a.Version is not AllVersionsSpec && a.Version.Min > currentVersion) .ToList(); if (futureApplicabilities.Count != 0) { return futureApplicabilities - .OrderBy(a => a.Version!.CompareTo(currentVersion)) + .OrderBy(a => a.Version!.Min.CompareTo(currentVersion)) .ThenBy(a => lifecycleOrder.GetValueOrDefault(a.Lifecycle, 999)) .First(); } diff --git a/src/Elastic.Documentation/AppliesTo/ApplicableTo.cs b/src/Elastic.Documentation/AppliesTo/ApplicableTo.cs index 5889a9964..94ff54d4c 100644 --- a/src/Elastic.Documentation/AppliesTo/ApplicableTo.cs +++ b/src/Elastic.Documentation/AppliesTo/ApplicableTo.cs @@ -64,9 +64,11 @@ public record ApplicableTo Product = AppliesCollection.GenerallyAvailable }; + private static readonly VersionSpec DefaultVersion = VersionSpec.TryParse("9.0", out var v) ? v! : AllVersionsSpec.Instance; + public static ApplicableTo Default { get; } = new() { - Stack = new AppliesCollection([new Applicability { Version = new SemVersion(9, 0, 0), Lifecycle = ProductLifecycle.GenerallyAvailable }]), + Stack = new AppliesCollection([new Applicability { Version = DefaultVersion, Lifecycle = ProductLifecycle.GenerallyAvailable }]), Serverless = ServerlessProjectApplicability.All }; diff --git a/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs b/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs index d3779e525..c8d987064 100644 --- a/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs +++ b/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs @@ -44,7 +44,7 @@ public class ApplicableToJsonConverter : JsonConverter string? type = null; string? subType = null; var lifecycle = ProductLifecycle.GenerallyAvailable; - SemVersion? version = null; + VersionSpec? version = null; while (reader.Read()) { @@ -72,8 +72,14 @@ public class ApplicableToJsonConverter : JsonConverter break; case "version": var versionStr = reader.GetString(); - if (versionStr != null && SemVersionConverter.TryParse(versionStr, out var v)) - version = v; + if (versionStr != null) + { + // Handle "all" explicitly for AllVersionsSpec + if (string.Equals(versionStr.Trim(), "all", StringComparison.OrdinalIgnoreCase)) + version = AllVersionsSpec.Instance; + else if (VersionSpec.TryParse(versionStr, out var v)) + version = v; + } break; } } diff --git a/src/Elastic.Documentation/AppliesTo/ApplicableToYamlConverter.cs b/src/Elastic.Documentation/AppliesTo/ApplicableToYamlConverter.cs index 1017207e1..d86e06541 100644 --- a/src/Elastic.Documentation/AppliesTo/ApplicableToYamlConverter.cs +++ b/src/Elastic.Documentation/AppliesTo/ApplicableToYamlConverter.cs @@ -256,10 +256,100 @@ private static bool TryGetApplicabilityOverTime(Dictionary dict if (target is null || (target is string s && string.IsNullOrWhiteSpace(s))) availability = AppliesCollection.GenerallyAvailable; else if (target is string stackString) + { availability = AppliesCollection.TryParse(stackString, diagnostics, out var a) ? a : null; + + if (availability is not null) + ValidateApplicabilityCollection(key, availability, diagnostics); + } return availability is not null; } + private static void ValidateApplicabilityCollection(string key, AppliesCollection collection, List<(Severity, string)> diagnostics) + { + var items = collection.ToList(); + + // Rule: Only one version declaration per lifecycle + var lifecycleGroups = items.GroupBy(a => a.Lifecycle).ToList(); + foreach (var group in lifecycleGroups) + { + var lifecycleVersionedItems = group.Where(a => a.Version is not null && + a.Version != AllVersionsSpec.Instance).ToList(); + if (lifecycleVersionedItems.Count > 1) + { + diagnostics.Add((Severity.Warning, + $"Key '{key}': Multiple version declarations for {group.Key} lifecycle. 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(); + + if (greaterThanItems.Count > 1) + { + diagnostics.Add((Severity.Warning, + $"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 + foreach (var item in items.Where(a => a.Version is { Kind: VersionSpecKind.Range })) + { + var spec = item.Version!; + if (spec.Min.CompareTo(spec.Max!) > 0) + { + diagnostics.Add((Severity.Warning, + $"Key '{key}', {item.Lifecycle}: Range has first version ({spec.Min.Major}.{spec.Min.Minor}) greater than last version ({spec.Max!.Major}.{spec.Max.Minor}).")); + } + } + + // Rule: No overlapping version ranges for the same key + var versionedItems = items.Where(a => a.Version is not null && + a.Version != AllVersionsSpec.Instance).ToList(); + + for (var i = 0; i < versionedItems.Count; i++) + { + for (var j = i + 1; j < versionedItems.Count; j++) + { + if (CheckVersionOverlap(versionedItems[i].Version!, versionedItems[j].Version!, out var overlapMsg)) + { + diagnostics.Add((Severity.Warning, + $"Key '{key}': Overlapping versions between {versionedItems[i].Lifecycle} and {versionedItems[j].Lifecycle}. {overlapMsg}")); + } + } + } + } + + private static bool CheckVersionOverlap(VersionSpec v1, VersionSpec v2, out string message) + { + message = string.Empty; + + // Get the effective ranges for each version spec + // For GreaterThanOrEqual: [min, infinity) + // For Range: [min, max] + // For Exact: [exact, exact] + + var (v1Min, v1Max) = GetEffectiveRange(v1); + var (v2Min, v2Max) = GetEffectiveRange(v2); + + var overlaps = v1Min.CompareTo(v2Max ?? new SemVersion(99999, 0, 0)) <= 0 && + v2Min.CompareTo(v1Max ?? new SemVersion(99999, 0, 0)) <= 0; + + if (overlaps) + message = $"Version ranges overlap."; + + return overlaps; + } + + private static (SemVersion min, SemVersion? max) GetEffectiveRange(VersionSpec spec) => spec.Kind switch + { + VersionSpecKind.Exact => (spec.Min, spec.Min), + VersionSpecKind.Range => (spec.Min, spec.Max), + VersionSpecKind.GreaterThanOrEqual => (spec.Min, null), + _ => throw new ArgumentOutOfRangeException(nameof(spec), spec.Kind, "Unknown VersionSpecKind") + }; + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => serializer.Invoke(value, type); } diff --git a/src/Elastic.Documentation/SemVersion.cs b/src/Elastic.Documentation/SemVersion.cs index 0516f22e0..e1cb736da 100644 --- a/src/Elastic.Documentation/SemVersion.cs +++ b/src/Elastic.Documentation/SemVersion.cs @@ -8,7 +8,7 @@ namespace Elastic.Documentation; -public class AllVersions() : SemVersion(9999, 9999, 9999) +public class AllVersions() : SemVersion(99999, 0, 0) { public static AllVersions Instance { get; } = new(); } diff --git a/src/Elastic.Documentation/Serialization/SourceGenerationContext.cs b/src/Elastic.Documentation/Serialization/SourceGenerationContext.cs index 7a97730a3..cde2ac16e 100644 --- a/src/Elastic.Documentation/Serialization/SourceGenerationContext.cs +++ b/src/Elastic.Documentation/Serialization/SourceGenerationContext.cs @@ -28,5 +28,6 @@ namespace Elastic.Documentation.Serialization; [JsonSerializable(typeof(Applicability))] [JsonSerializable(typeof(ProductLifecycle))] [JsonSerializable(typeof(SemVersion))] +[JsonSerializable(typeof(VersionSpec))] [JsonSerializable(typeof(string[]))] public sealed partial class SourceGenerationContext : JsonSerializerContext; diff --git a/src/Elastic.Documentation/VersionSpec.cs b/src/Elastic.Documentation/VersionSpec.cs new file mode 100644 index 000000000..34558ef65 --- /dev/null +++ b/src/Elastic.Documentation/VersionSpec.cs @@ -0,0 +1,256 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// 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 System.Diagnostics.CodeAnalysis; + +namespace Elastic.Documentation; + +public sealed class AllVersionsSpec : VersionSpec +{ + private static readonly SemVersion AllVersionsSemVersion = new(99999, 0, 0); + + private AllVersionsSpec() : base(AllVersionsSemVersion, null, VersionSpecKind.GreaterThanOrEqual) + { + } + + public static AllVersionsSpec Instance { get; } = new(); + + public override string ToString() => "all"; +} + +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 +} + +/// +/// Represents a version specification that can be a single version with greater-than-or-equal semantics, +/// a range of versions, or an exact version match. +/// +public class VersionSpec : IComparable, IEquatable +{ + /// + /// The minimum version (or the exact version for Exact kind). + /// + public SemVersion Min { get; } + + /// + /// The maximum version for ranges. Null for GreaterThanOrEqual and Exact kinds. + /// + public SemVersion? Max { get; } + + /// + /// The kind of version specification. + /// + public VersionSpecKind Kind { get; } + + // Internal constructor to prevent direct instantiation outside of TryParse + // except for AllVersionsSpec which needs to inherit from this class + protected VersionSpec(SemVersion min, SemVersion? max, VersionSpecKind kind) + { + Min = min; + Max = max; + Kind = kind; + } + + /// + /// Creates an Exact version spec from a SemVersion. + /// + public static VersionSpec Exact(SemVersion version) => new(version, null, VersionSpecKind.Exact); + + /// + /// Creates a Range version spec from two SemVersions. + /// + public static VersionSpec Range(SemVersion min, SemVersion max) => new(min, max, VersionSpecKind.Range); + + /// + /// Creates a GreaterThanOrEqual version spec from a SemVersion. + /// + public static VersionSpec GreaterThanOrEqual(SemVersion min) => new(min, null, VersionSpecKind.GreaterThanOrEqual); + + /// + /// Tries to parse a version specification string. + /// Supports: x.x, x.x+, x.x.x, x.x.x+ (gte), x.x-y.y (range), =x.x (exact) + /// + public static bool TryParse(string? input, [NotNullWhen(true)] out VersionSpec? spec) + { + spec = null; + + if (string.IsNullOrWhiteSpace(input)) + return false; + + var trimmed = input.Trim(); + + // Check for exact syntax: =x.x or =x.x.x + if (trimmed.StartsWith('=')) + { + var versionPart = trimmed[1..]; + if (!TryParseVersion(versionPart, out var version)) + return false; + + spec = new(version, null, VersionSpecKind.Exact); + return true; + } + + // Check for range syntax: x.x-y.y or x.x.x-y.y.y + var dashIndex = FindRangeSeparator(trimmed); + if (dashIndex > 0) + { + var minPart = trimmed[..dashIndex]; + var maxPart = trimmed[(dashIndex + 1)..]; + + if (!TryParseVersion(minPart, out var minVersion) || + !TryParseVersion(maxPart, out var maxVersion)) + return false; + + spec = new(minVersion, maxVersion, VersionSpecKind.Range); + return true; + } + + // Otherwise, it's greater-than-or-equal syntax + // Strip trailing + if present + var versionString = trimmed.EndsWith('+') ? trimmed[..^1] : trimmed; + + if (!TryParseVersion(versionString, out var gteVersion)) + return false; + + spec = new(gteVersion, null, VersionSpecKind.GreaterThanOrEqual); + return true; + } + + /// + /// Finds the position of the dash separator in a range specification. + /// Returns -1 if no valid range separator is found. + /// + private static int FindRangeSeparator(string input) + { + // Look for a dash that's not part of a prerelease version + // We need to distinguish between "9.0-9.1" (range) and "9.0-alpha" (prerelease) + // Strategy: Find dashes and check if what follows looks like a version number + + for (var i = 0; i < input.Length; i++) + { + if (input[i] == '-') + { + // Check if there's content before and after the dash + if (i == 0 || i == input.Length - 1) + continue; + + // Check if the character after dash is a digit (indicating a version) + if (i + 1 < input.Length && char.IsDigit(input[i + 1])) + { + // Also verify that what comes before looks like a version + var beforeDash = input[..i]; + if (TryParseVersion(beforeDash, out _)) + return i; + } + } + } + + return -1; + } + + /// + /// Tries to parse a version string, normalizing minor versions to include patch 0. + /// + private static bool TryParseVersion(string input, [NotNullWhen(true)] out SemVersion? version) + { + version = null; + + if (string.IsNullOrWhiteSpace(input)) + return false; + + var trimmed = input.Trim(); + + // Try to parse as-is first + if (SemVersion.TryParse(trimmed, out version)) + return true; + + // If that fails, try appending .0 to support minor version format (e.g., "9.2" -> "9.2.0") + if (SemVersion.TryParse(trimmed + ".0", out version)) + return true; + + return false; + } + + /// + /// Returns the canonical string representation of this version spec. + /// Format: "9.2+" for GreaterThanOrEqual, "9.0-9.1" for Range, "=9.2" for Exact + /// + public override string ToString() => Kind switch + { + VersionSpecKind.Exact => $"={Min.Major}.{Min.Minor}", + VersionSpecKind.Range => $"{Min.Major}.{Min.Minor}-{Max!.Major}.{Max.Minor}", + VersionSpecKind.GreaterThanOrEqual => $"{Min.Major}.{Min.Minor}+", + _ => throw new ArgumentOutOfRangeException(nameof(Kind), Kind, null) + }; + + /// + /// Compares this VersionSpec to another for sorting. + /// Uses Max for ranges, otherwise uses Min. + /// + public int CompareTo(VersionSpec? other) + { + if (other is null) + return 1; + + // For sorting, we want to compare the "highest" version in each spec + var thisCompareVersion = Kind == VersionSpecKind.Range && Max is not null ? Max : Min; + var otherCompareVersion = other.Kind == VersionSpecKind.Range && other.Max is not null ? other.Max : other.Min; + + return thisCompareVersion.CompareTo(otherCompareVersion); + } + + /// + /// Checks if this VersionSpec is equal to another. + /// + public bool Equals(VersionSpec? other) + { + if (other is null) + return false; + + if (ReferenceEquals(this, other)) + return true; + + return Kind == other.Kind && Min.Equals(other.Min) && + (Max?.Equals(other.Max) ?? (other.Max is null)); + } + + public override bool Equals(object? obj) => obj is VersionSpec other && Equals(other); + + public override int GetHashCode() => HashCode.Combine(Kind, Min, Max); + + public static bool operator ==(VersionSpec? left, VersionSpec? right) + { + if (left is null) + return right is null; + return left.Equals(right); + } + + 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 || 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; + + /// + /// Explicit conversion from string to VersionSpec + /// + public static explicit operator VersionSpec(string s) + { + if (TryParse(s, out var spec)) + return spec!; + throw new ArgumentException($"'{s}' is not a valid version specification string."); + } +} diff --git a/src/Elastic.Markdown/Myst/Components/ApplicabilityRenderer.cs b/src/Elastic.Markdown/Myst/Components/ApplicabilityRenderer.cs index 8ffe34306..f223ae7b8 100644 --- a/src/Elastic.Markdown/Myst/Components/ApplicabilityRenderer.cs +++ b/src/Elastic.Markdown/Myst/Components/ApplicabilityRenderer.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 System.Diagnostics.CodeAnalysis; using Elastic.Documentation; using Elastic.Documentation.AppliesTo; using Elastic.Documentation.Configuration.Versions; @@ -16,6 +15,7 @@ public record ApplicabilityRenderData( string Version, string TooltipText, string LifecycleClass, + string LifecycleName, bool ShowLifecycleName, bool ShowVersion, bool HasMultipleLifecycles = false @@ -29,19 +29,29 @@ public ApplicabilityRenderData RenderApplicability( { var lifecycleClass = applicability.GetLifeCycleName().ToLowerInvariant().Replace(" ", "-"); var lifecycleFull = GetLifecycleFullText(applicability.Lifecycle); - var realVersion = TryGetRealVersion(applicability, out var v) ? v : null; - var tooltipText = BuildTooltipText(applicability, applicabilityDefinition, versioningSystem, realVersion, lifecycleFull); - var badgeLifecycleText = BuildBadgeLifecycleText(applicability, versioningSystem, realVersion, allApplications); + var tooltipText = BuildTooltipText(applicability, applicabilityDefinition, versioningSystem, lifecycleFull); + var badgeLifecycleText = BuildBadgeLifecycleText(applicability, versioningSystem, allApplications); var showLifecycle = applicability.Lifecycle != ProductLifecycle.GenerallyAvailable && string.IsNullOrEmpty(badgeLifecycleText); - var showVersion = applicability.Version is not null and not AllVersions && versioningSystem.Current >= applicability.Version; - var version = applicability.Version?.ToString() ?? ""; + + // Determine if we should show version based on VersionSpec + var versionDisplay = GetBadgeVersionText(applicability.Version, versioningSystem); + 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)) + { + versionDisplay = versionDisplay.TrimEnd('+'); + } + return new ApplicabilityRenderData( BadgeLifecycleText: badgeLifecycleText, - Version: version, + Version: versionDisplay, TooltipText: tooltipText, LifecycleClass: lifecycleClass, + LifecycleName: applicability.GetLifeCycleName(), ShowLifecycleName: showLifecycle, ShowVersion: showVersion ); @@ -54,9 +64,26 @@ public ApplicabilityRenderData RenderCombinedApplicability( AppliesCollection allApplications) { var applicabilityList = applicabilities.ToList(); - var primaryApplicability = ApplicabilitySelector.GetPrimaryApplicability(applicabilityList, versioningSystem.Current); - var primaryRenderData = RenderApplicability(primaryApplicability, applicabilityDefinition, versioningSystem, allApplications); + // Sort by lifecycle priority (GA > Beta > Preview > etc.) to determine display order + var sortedApplicabilities = applicabilityList + .OrderBy(a => GetLifecycleOrder(a.Lifecycle)) + .ThenByDescending(a => a.Version?.Min ?? new SemVersion(0, 0, 0)) + .ToList(); + + var primaryLifecycle = sortedApplicabilities.First(); + + var primaryRender = RenderApplicability(primaryLifecycle, applicabilityDefinition, versioningSystem, allApplications); + + // If the primary lifecycle returns an empty badge text (indicating "use previous lifecycle") + // and we have multiple lifecycles, use the next lifecycle in priority order + var applicabilityToDisplay = string.IsNullOrEmpty(primaryRender.BadgeLifecycleText) && + string.IsNullOrEmpty(primaryRender.Version) && + sortedApplicabilities.Count >= 2 + ? sortedApplicabilities[1] + : primaryLifecycle; + + var primaryRenderData = RenderApplicability(applicabilityToDisplay, applicabilityDefinition, versioningSystem, allApplications); var combinedTooltip = BuildCombinedTooltipText(applicabilityList, applicabilityDefinition, versioningSystem); // Check if there are multiple different lifecycles @@ -70,7 +97,6 @@ public ApplicabilityRenderData RenderCombinedApplicability( }; } - private static string BuildCombinedTooltipText( List applicabilities, ApplicabilityMappings.ApplicabilityDefinition applicabilityDefinition, @@ -80,17 +106,17 @@ private static string BuildCombinedTooltipText( // Order by the same logic as primary selection: available first (by version desc), then future (by version asc) var orderedApplicabilities = applicabilities - .OrderByDescending(a => a.Version is null || a.Version is AllVersions || a.Version <= versioningSystem.Current ? 1 : 0) - .ThenByDescending(a => a.Version ?? new SemVersion(0, 0, 0)) - .ThenBy(a => a.Version ?? new SemVersion(0, 0, 0)) + .OrderByDescending(a => a.Version is null || a.Version is AllVersionsSpec || + (a.Version is { } vs && vs.Min <= versioningSystem.Current) ? 1 : 0) + .ThenByDescending(a => a.Version?.Min ?? new SemVersion(0, 0, 0)) + .ThenBy(a => a.Version?.Min ?? new SemVersion(0, 0, 0)) .ToList(); foreach (var applicability in orderedApplicabilities) { - var realVersion = TryGetRealVersion(applicability, out var v) ? v : null; var lifecycleFull = GetLifecycleFullText(applicability.Lifecycle); - var heading = CreateApplicabilityHeading(applicability, applicabilityDefinition, realVersion); - var tooltipText = BuildTooltipText(applicability, applicabilityDefinition, versioningSystem, realVersion, lifecycleFull); + var heading = CreateApplicabilityHeading(applicability, applicabilityDefinition); + var tooltipText = BuildTooltipText(applicability, applicabilityDefinition, versioningSystem, lifecycleFull); // language=html tooltipParts.Add($"
{heading}{tooltipText}
"); } @@ -98,11 +124,10 @@ private static string BuildCombinedTooltipText( return string.Join("\n\n", tooltipParts); } - private static string CreateApplicabilityHeading(Applicability applicability, ApplicabilityMappings.ApplicabilityDefinition applicabilityDefinition, - SemVersion? realVersion) + private static string CreateApplicabilityHeading(Applicability applicability, ApplicabilityMappings.ApplicabilityDefinition applicabilityDefinition) { var lifecycleName = applicability.GetLifeCycleName(); - var versionText = realVersion is not null ? $" {realVersion}" : ""; + var versionText = applicability.Version is not null ? $" {applicability.Version.Min}" : ""; // language=html return $"""{applicabilityDefinition.DisplayName} {lifecycleName}{versionText}:"""; } @@ -114,7 +139,7 @@ private static string CreateApplicabilityHeading(Applicability applicability, Ap ProductLifecycle.TechnicalPreview => "Available in technical preview", ProductLifecycle.Deprecated => "Deprecated", ProductLifecycle.Removed => "Removed", - ProductLifecycle.Unavailable => "Not available", + ProductLifecycle.Unavailable => "Unavailable", _ => "" }; @@ -122,14 +147,15 @@ private static string BuildTooltipText( Applicability applicability, ApplicabilityMappings.ApplicabilityDefinition applicabilityDefinition, VersioningSystem versioningSystem, - SemVersion? realVersion, string lifecycleFull) { var tooltipText = ""; - tooltipText = realVersion is not null - ? realVersion <= versioningSystem.Current - ? $"{lifecycleFull} on {applicabilityDefinition.DisplayName} version {realVersion} and later unless otherwise specified." + // Check if a specific version is provided + if (applicability.Version is not null && applicability.Version != AllVersionsSpec.Instance) + { + tooltipText = applicability.Version.Min <= versioningSystem.Current + ? $"{lifecycleFull} on {applicabilityDefinition.DisplayName} version {applicability.Version.Min} and later unless otherwise specified." : applicability.Lifecycle switch { ProductLifecycle.GenerallyAvailable @@ -142,8 +168,21 @@ or ProductLifecycle.TechnicalPreview ProductLifecycle.Removed => $"We plan to remove this functionality in a future {applicabilityDefinition.DisplayName} update. Subject to change.", _ => tooltipText + }; + } + else + { + // No version specified - check if we should show base version + tooltipText = versioningSystem.Base.Major != AllVersionsSpec.Instance.Min.Major + ? applicability.Lifecycle switch + { + ProductLifecycle.Removed => + $"Removed in {applicabilityDefinition.DisplayName} {versioningSystem.Base.Major}.{versioningSystem.Base.Minor}.", + _ => + $"{lifecycleFull} since {versioningSystem.Base.Major}.{versioningSystem.Base.Minor}." } - : $"{lifecycleFull} on {applicabilityDefinition.DisplayName} unless otherwise specified."; + : $"{lifecycleFull} on {applicabilityDefinition.DisplayName} unless otherwise specified."; + } var disclaimer = GetDisclaimer(applicability.Lifecycle, versioningSystem.Id); if (disclaimer is not null) @@ -167,40 +206,102 @@ or ProductLifecycle.TechnicalPreview private static string BuildBadgeLifecycleText( Applicability applicability, VersioningSystem versioningSystem, - SemVersion? realVersion, AppliesCollection allApplications) { var badgeText = ""; - if (realVersion is not null && realVersion > versioningSystem.Current) + var versionSpec = applicability.Version; + + if (versionSpec is not null && versionSpec != AllVersionsSpec.Instance) { - badgeText = applicability.Lifecycle switch + var isMinReleased = versionSpec.Min <= versioningSystem.Current; + var isMaxReleased = versionSpec.Max is not null && versionSpec.Max <= versioningSystem.Current; + + // 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); + + // Check lifecycle count for "use previous lifecycle" logic + if (shouldShowPlanned) { - ProductLifecycle.TechnicalPreview => "Planned", - ProductLifecycle.Beta => "Planned", - ProductLifecycle.GenerallyAvailable => - allApplications.Any(a => a.Lifecycle is ProductLifecycle.TechnicalPreview or ProductLifecycle.Beta) - ? "GA planned" - : "Planned", - ProductLifecycle.Deprecated => "Deprecation planned", - ProductLifecycle.Removed => "Removal planned", - ProductLifecycle.Planned => "Planned", - ProductLifecycle.Unavailable => "Unavailable", - _ => badgeText - }; + var lifecycleCount = allApplications.Count; + + // If lifecycle count >= 2, we should use previous lifecycle instead of showing "Planned" + if (lifecycleCount >= 2) + return string.Empty; + + // Otherwise show planned badge (lifecycle count == 1) + badgeText = applicability.Lifecycle switch + { + ProductLifecycle.TechnicalPreview => "Planned", + ProductLifecycle.Beta => "Planned", + ProductLifecycle.GenerallyAvailable => "Planned", + ProductLifecycle.Deprecated => "Deprecation planned", + ProductLifecycle.Removed => "Removal planned", + ProductLifecycle.Planned => "Planned", + ProductLifecycle.Unavailable => "Unavailable", + _ => badgeText + }; + } } return badgeText; } - private static bool TryGetRealVersion(Applicability applicability, [NotNullWhen(true)] out SemVersion? version) + /// + /// Gets the version to display in badges, handling VersionSpec kinds + /// + private static string GetBadgeVersionText(VersionSpec? versionSpec, VersioningSystem versioningSystem) { - version = null; - if (applicability.Version is not null && applicability.Version != AllVersions.Instance) + // When no version is specified, check if we should show the base version + if (versionSpec is null) { - version = applicability.Version; - return true; + return versioningSystem.Base != AllVersionsSpec.Instance.Min + ? $"{versioningSystem.Base.Major}.{versioningSystem.Base.Minor}+" + : string.Empty; // Otherwise, this is an unversioned product, show no version } - return false; + var kind = versionSpec.Kind; + var min = versionSpec.Min; + var max = versionSpec.Max; + + // Check if versions are released + var minReleased = min <= versioningSystem.Current; + var maxReleased = max is not null && max <= versioningSystem.Current; + + return kind switch + { + VersionSpecKind.GreaterThanOrEqual => minReleased + ? $"{min.Major}.{min.Minor}+" + : string.Empty, + + VersionSpecKind.Range => maxReleased + ? $"{min.Major}.{min.Minor}-{max!.Major}.{max.Minor}" + : minReleased + ? $"{min.Major}.{min.Minor}+" + : string.Empty, + + VersionSpecKind.Exact => minReleased + ? $"{min.Major}.{min.Minor}" + : string.Empty, + + _ => string.Empty + }; } + private static int GetLifecycleOrder(ProductLifecycle lifecycle) => lifecycle switch + { + ProductLifecycle.GenerallyAvailable => 0, + ProductLifecycle.Beta => 1, + ProductLifecycle.TechnicalPreview => 2, + ProductLifecycle.Planned => 3, + ProductLifecycle.Deprecated => 4, + ProductLifecycle.Removed => 5, + ProductLifecycle.Unavailable => 6, + _ => 999 + }; + + /// + /// Checks if a version should be considered released + /// + private static bool IsVersionReleased(SemVersion version, VersioningSystem versioningSystem) => version <= versioningSystem.Current; } diff --git a/src/Elastic.Markdown/Myst/Components/ApplicableToComponent.cshtml b/src/Elastic.Markdown/Myst/Components/ApplicableToComponent.cshtml index a9831405c..815050a7a 100644 --- a/src/Elastic.Markdown/Myst/Components/ApplicableToComponent.cshtml +++ b/src/Elastic.Markdown/Myst/Components/ApplicableToComponent.cshtml @@ -12,7 +12,7 @@ @if (item.RenderData.ShowLifecycleName) { - @item.PrimaryApplicability.GetLifeCycleName() + @item.RenderData.LifecycleName } @if (item.RenderData.ShowVersion) { diff --git a/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterRoundTripTests.cs b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterRoundTripTests.cs index 3b22299f8..1bf241171 100644 --- a/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterRoundTripTests.cs +++ b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterRoundTripTests.cs @@ -36,8 +36,8 @@ public void RoundTripStackWithVersion() { Stack = new AppliesCollection( [ - new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = new SemVersion(8, 0, 0) }, - new Applicability { Lifecycle = ProductLifecycle.Beta, Version = new SemVersion(7, 17, 0) } + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"8.0.0" }, + new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"7.17.0" } ]) }; @@ -57,8 +57,8 @@ public void RoundTripDeploymentAllProperties() Deployment = new DeploymentApplicability { Self = AppliesCollection.GenerallyAvailable, - Ece = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"3.0.0" }]), - Eck = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"2.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 } }; @@ -82,8 +82,8 @@ public void RoundTripServerlessAllProperties() Serverless = new ServerlessProjectApplicability { Elasticsearch = AppliesCollection.GenerallyAvailable, - Observability = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = AllVersions.Instance }]), - Security = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"1.0.0" }]) + Observability = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = AllVersionsSpec.Instance }]), + Security = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"1.0.0" }]) } }; @@ -140,9 +140,9 @@ public void RoundTripProductApplicabilityMultipleProducts() ProductApplicability = new ProductApplicability { Ecctl = AppliesCollection.GenerallyAvailable, - Curator = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (SemVersion)"5.0.0" }]), - ApmAgentDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.2.0" }]), - EdotDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"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" }]) } }; @@ -165,27 +165,27 @@ public void RoundTripAllProductApplicabilityProperties() ProductApplicability = new ProductApplicability { Ecctl = AppliesCollection.GenerallyAvailable, - Curator = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (SemVersion)"5.0.0" }]), - ApmAgentAndroid = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"1.0.0" }]), - ApmAgentDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.2.0" }]), - ApmAgentGo = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"2.0.0" }]), - ApmAgentIos = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (SemVersion)"0.5.0" }]), - ApmAgentJava = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.30.0" }]), - ApmAgentNode = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"3.0.0" }]), - ApmAgentPhp = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.8.0" }]), - ApmAgentPython = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"6.0.0" }]), - ApmAgentRuby = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"4.0.0" }]), - ApmAgentRumJs = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"5.0.0" }]), - EdotIos = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.9.0" }]), - EdotAndroid = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.8.0" }]), - EdotDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.9.0" }]), - EdotJava = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.7.0" }]), - EdotNode = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.6.0" }]), - EdotPhp = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.5.0" }]), - EdotPython = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.4.0" }]), - EdotCfAws = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (SemVersion)"0.3.0" }]), - EdotCfAzure = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (SemVersion)"0.2.0" }]), - EdotCollector = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.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" }]), + 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" }]) } }; @@ -225,27 +225,27 @@ public void RoundTripComplexAllFieldsPopulated() { Stack = new AppliesCollection( [ - new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.0.0" }, - new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"7.17.0" } + 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 = (SemVersion)"3.0.0" }]), - Eck = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"2.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 = AllVersions.Instance }]), - Security = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"1.0.0" }]) + 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 = (SemVersion)"1.2.0" }]) + ApmAgentDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"1.2.0" }]) } }; @@ -274,7 +274,7 @@ public void RoundTripAllLifecycles() { var lifecycles = Enum.GetValues(); var applicabilities = lifecycles.Select(lc => - new Applicability { Lifecycle = lc, Version = (SemVersion)"1.0.0" } + new Applicability { Lifecycle = lc, Version = (VersionSpec)"1.0.0" } ).ToArray(); var original = new ApplicableTo @@ -297,10 +297,10 @@ public void RoundTripMultipleApplicabilitiesInCollection() { Stack = new AppliesCollection( [ - new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.0.0" }, - new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"7.17.0" }, - new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (SemVersion)"7.16.0" }, - new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (SemVersion)"6.0.0" } + 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" } ]) }; @@ -345,16 +345,16 @@ public void RoundTripAllVersionsSerializesAsSemanticVersion() { var original = new ApplicableTo { - Stack = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = AllVersions.Instance }]) + Stack = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = AllVersionsSpec.Instance }]) }; var json = JsonSerializer.Serialize(original, _options); - json.Should().Contain("\"version\": \"9999.9999.9999\""); + json.Should().Contain("\"version\": \"all\""); var deserialized = JsonSerializer.Deserialize(json, _options); deserialized.Should().NotBeNull(); deserialized!.Stack.Should().NotBeNull(); - deserialized.Stack!.First().Version.Should().Be(AllVersions.Instance); + deserialized.Stack!.First().Version.Should().Be(AllVersionsSpec.Instance); } [Fact] @@ -365,7 +365,7 @@ public void RoundTripProductAndProductApplicabilityBothPresent() Product = AppliesCollection.GenerallyAvailable, ProductApplicability = new ProductApplicability { - Ecctl = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"1.0.0" }]) + Ecctl = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"1.0.0" }]) } }; diff --git a/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs index fbff52703..4e47b5edd 100644 --- a/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs +++ b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs @@ -2,6 +2,7 @@ // 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 System.Text.Encodings.Web; using System.Text.Json; using Elastic.Documentation; using Elastic.Documentation.AppliesTo; @@ -13,7 +14,8 @@ public class ApplicableToJsonConverterSerializationTests { private readonly JsonSerializerOptions _options = new() { - WriteIndented = true + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; [Fact] @@ -34,7 +36,7 @@ public void SerializeStackProducesCorrectJson() "type": "stack", "sub_type": "stack", "lifecycle": "ga", - "version": "9999.9999.9999" + "version": "all" } ] """); @@ -49,7 +51,7 @@ public void SerializeStackWithVersionProducesCorrectJson() new Applicability { Lifecycle = ProductLifecycle.Beta, - Version = (SemVersion)"8.0.0" + Version = (VersionSpec)"8.0.0" } ]) }; @@ -64,7 +66,7 @@ public void SerializeStackWithVersionProducesCorrectJson() "type": "stack", "sub_type": "stack", "lifecycle": "beta", - "version": "8.0.0" + "version": "8.0+" } ] """); @@ -80,12 +82,12 @@ public void SerializeMultipleApplicabilitiesProducesCorrectJson() new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, - Version = (SemVersion)"8.0.0" + Version = (VersionSpec)"8.0.0" }, new Applicability { Lifecycle = ProductLifecycle.Beta, - Version = (SemVersion)"7.17.0" + Version = (VersionSpec)"7.17.0" } ]) }; @@ -100,13 +102,13 @@ public void SerializeMultipleApplicabilitiesProducesCorrectJson() "type": "stack", "sub_type": "stack", "lifecycle": "ga", - "version": "8.0.0" + "version": "8.0+" }, { "type": "stack", "sub_type": "stack", "lifecycle": "beta", - "version": "7.17.0" + "version": "7.17+" } ] """); @@ -123,7 +125,7 @@ public void SerializeDeploymentProducesCorrectJson() new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, - Version = (SemVersion)"3.0.0" + Version = (VersionSpec)"3.0.0" } ]), Ess = AppliesCollection.GenerallyAvailable @@ -140,13 +142,13 @@ public void SerializeDeploymentProducesCorrectJson() "type": "deployment", "sub_type": "ece", "lifecycle": "ga", - "version": "3.0.0" + "version": "3.0+" }, { "type": "deployment", "sub_type": "ess", "lifecycle": "ga", - "version": "9999.9999.9999" + "version": "all" } ] """); @@ -163,7 +165,7 @@ public void SerializeServerlessProducesCorrectJson() new Applicability { Lifecycle = ProductLifecycle.Beta, - Version = (SemVersion)"1.0.0" + Version = (VersionSpec)"1.0.0" } ]), Security = AppliesCollection.GenerallyAvailable @@ -180,13 +182,13 @@ public void SerializeServerlessProducesCorrectJson() "type": "serverless", "sub_type": "elasticsearch", "lifecycle": "beta", - "version": "1.0.0" + "version": "1.0+" }, { "type": "serverless", "sub_type": "security", "lifecycle": "ga", - "version": "9999.9999.9999" + "version": "all" } ] """); @@ -201,7 +203,7 @@ public void SerializeProductProducesCorrectJson() new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, - Version = (SemVersion)"0.5.0" + Version = (VersionSpec)"0.5.0" } ]) }; @@ -216,7 +218,7 @@ public void SerializeProductProducesCorrectJson() "type": "product", "sub_type": "product", "lifecycle": "preview", - "version": "0.5.0" + "version": "0.5+" } ] """); @@ -233,7 +235,7 @@ public void SerializeProductApplicabilityProducesCorrectJson() new Applicability { Lifecycle = ProductLifecycle.Deprecated, - Version = (SemVersion)"5.0.0" + Version = (VersionSpec)"5.0.0" } ]), ApmAgentDotnet = AppliesCollection.GenerallyAvailable @@ -250,13 +252,13 @@ public void SerializeProductApplicabilityProducesCorrectJson() "type": "product", "sub_type": "ecctl", "lifecycle": "deprecated", - "version": "5.0.0" + "version": "5.0+" }, { "type": "product", "sub_type": "apm-agent-dotnet", "lifecycle": "ga", - "version": "9999.9999.9999" + "version": "all" } ] """); @@ -272,27 +274,27 @@ public void SerializeAllLifecyclesProducesCorrectJson() new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, - Version = (SemVersion)"1.0.0" + Version = (VersionSpec)"1.0.0" }, new Applicability { Lifecycle = ProductLifecycle.Beta, - Version = (SemVersion)"1.0.0" + Version = (VersionSpec)"1.0.0" }, new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, - Version = (SemVersion)"1.0.0" + Version = (VersionSpec)"1.0.0" }, new Applicability { Lifecycle = ProductLifecycle.Deprecated, - Version = (SemVersion)"1.0.0" + Version = (VersionSpec)"1.0.0" }, new Applicability { Lifecycle = ProductLifecycle.Removed, - Version = (SemVersion)"1.0.0" + Version = (VersionSpec)"1.0.0" } ]) }; @@ -315,7 +317,7 @@ public void SerializeComplexProducesCorrectJson() new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, - Version = (SemVersion)"8.0.0" + Version = (VersionSpec)"8.0.0" } ]), Deployment = new DeploymentApplicability @@ -364,7 +366,7 @@ public void SerializeValidatesJsonStructure() new Applicability { Lifecycle = ProductLifecycle.Beta, - Version = (SemVersion)"3.0.0" + Version = (VersionSpec)"3.0.0" } ]) } @@ -383,12 +385,12 @@ public void SerializeValidatesJsonStructure() stackEntry.GetProperty("type").GetString().Should().Be("stack"); stackEntry.GetProperty("sub_type").GetString().Should().Be("stack"); stackEntry.GetProperty("lifecycle").GetString().Should().Be("ga"); - stackEntry.GetProperty("version").GetString().Should().Be("9999.9999.9999"); + stackEntry.GetProperty("version").GetString().Should().Be("all"); var deploymentEntry = array[1]; deploymentEntry.GetProperty("type").GetString().Should().Be("deployment"); deploymentEntry.GetProperty("sub_type").GetString().Should().Be("ece"); deploymentEntry.GetProperty("lifecycle").GetString().Should().Be("beta"); - deploymentEntry.GetProperty("version").GetString().Should().Be("3.0.0"); + deploymentEntry.GetProperty("version").GetString().Should().Be("3.0+"); } } diff --git a/tests/Elastic.Markdown.Tests/AppliesTo/ProductApplicabilityToStringTests.cs b/tests/Elastic.Markdown.Tests/AppliesTo/ProductApplicabilityToStringTests.cs index 3cefe5c4a..6e76d6f84 100644 --- a/tests/Elastic.Markdown.Tests/AppliesTo/ProductApplicabilityToStringTests.cs +++ b/tests/Elastic.Markdown.Tests/AppliesTo/ProductApplicabilityToStringTests.cs @@ -50,7 +50,7 @@ public void ProductApplicabilityToStringWithSomePropertiesOnlyIncludesSetPropert var productApplicability = new ProductApplicability { ApmAgentDotnet = AppliesCollection.GenerallyAvailable, - Ecctl = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = new SemVersion(1, 0, 0) }]) + Ecctl = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = VersionSpec.TryParse("1.0.0", out var v) ? v : null }]) }; var result = productApplicability.ToString(); diff --git a/tests/Elastic.Markdown.Tests/Directives/ApplicabilitySwitchTests.cs b/tests/Elastic.Markdown.Tests/Directives/ApplicabilitySwitchTests.cs index f0f372d2d..b60ea8d17 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ApplicabilitySwitchTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ApplicabilitySwitchTests.cs @@ -227,11 +227,12 @@ public void GeneratesDeterministicSyncKeysAcrossMultipleRuns() var expectedKeys = new Dictionary { // These are the actual SHA256-based hashes that should never change - { "stack: ga 9.1", "applies-031B7112" }, - { "stack: preview 9.0", "applies-361F73DC" }, - { "ess: ga 8.11", "applies-32E204F7" }, - { "deployment: { ece: ga 9.0, ess: ga 9.1 }", "applies-D099CDEF" }, - { "serverless: all", "applies-A34B17C6" }, + // (unless the version format actually changes) + { "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" }, + { "serverless: all", "applies-A34B17C6" } }; foreach (var (definition, expectedKey) in expectedKeys) diff --git a/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs b/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs index e2090cd0c..def825530 100644 --- a/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs +++ b/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs @@ -47,7 +47,7 @@ public void SerializeDocumentWithStackAppliesToProducesCorrectJson() stackEntry.GetProperty("type").GetString().Should().Be("stack"); stackEntry.GetProperty("sub_type").GetString().Should().Be("stack"); stackEntry.GetProperty("lifecycle").GetString().Should().Be("ga"); - stackEntry.GetProperty("version").GetString().Should().Be("9999.9999.9999"); + stackEntry.GetProperty("version").GetString().Should().Be("all"); } [Fact] @@ -64,7 +64,7 @@ public void SerializeDocumentWithDeploymentAppliesToProducesCorrectJson() Deployment = new DeploymentApplicability { Ess = AppliesCollection.GenerallyAvailable, - Ece = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"3.5.0" }]) + Ece = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"3.5.0" }]) } } }; @@ -82,14 +82,14 @@ public void SerializeDocumentWithDeploymentAppliesToProducesCorrectJson() essEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); essEntry.GetProperty("type").GetString().Should().Be("deployment"); essEntry.GetProperty("lifecycle").GetString().Should().Be("ga"); - essEntry.GetProperty("version").GetString().Should().Be("9999.9999.9999"); + essEntry.GetProperty("version").GetString().Should().Be("all"); // Verify ECE entry var eceEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "ece"); eceEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); eceEntry.GetProperty("type").GetString().Should().Be("deployment"); eceEntry.GetProperty("lifecycle").GetString().Should().Be("beta"); - eceEntry.GetProperty("version").GetString().Should().Be("3.5.0"); + eceEntry.GetProperty("version").GetString().Should().Be("3.5+"); } [Fact] @@ -105,8 +105,8 @@ public void SerializeDocumentWithServerlessAppliesToProducesCorrectJson() { Serverless = new ServerlessProjectApplicability { - Elasticsearch = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.0.0" }]), - Security = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (SemVersion)"1.0.0" }]) + 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" }]) } } }; @@ -124,14 +124,14 @@ public void SerializeDocumentWithServerlessAppliesToProducesCorrectJson() esEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); esEntry.GetProperty("type").GetString().Should().Be("serverless"); esEntry.GetProperty("lifecycle").GetString().Should().Be("ga"); - esEntry.GetProperty("version").GetString().Should().Be("8.0.0"); + esEntry.GetProperty("version").GetString().Should().Be("8.0+"); // Verify security entry var secEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "security"); secEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); secEntry.GetProperty("type").GetString().Should().Be("serverless"); secEntry.GetProperty("lifecycle").GetString().Should().Be("preview"); - secEntry.GetProperty("version").GetString().Should().Be("1.0.0"); + secEntry.GetProperty("version").GetString().Should().Be("1.0+"); } [Fact] @@ -145,7 +145,7 @@ public void SerializeDocumentWithProductAppliesToProducesCorrectJson() SearchTitle = "Product Test", Applies = new ApplicableTo { - Product = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"2.0.0" }]) + Product = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"2.0.0" }]) } }; @@ -161,7 +161,7 @@ public void SerializeDocumentWithProductAppliesToProducesCorrectJson() productEntry.GetProperty("type").GetString().Should().Be("product"); productEntry.GetProperty("sub_type").GetString().Should().Be("product"); productEntry.GetProperty("lifecycle").GetString().Should().Be("beta"); - productEntry.GetProperty("version").GetString().Should().Be("2.0.0"); + productEntry.GetProperty("version").GetString().Should().Be("2.0+"); } [Fact] @@ -177,8 +177,8 @@ public void SerializeDocumentWithProductApplicabilityProducesCorrectJson() { ProductApplicability = new ProductApplicability { - ApmAgentDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.5.0" }]), - ApmAgentNode = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (SemVersion)"2.0.0" }]) + 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" }]) } } }; @@ -196,14 +196,14 @@ public void SerializeDocumentWithProductApplicabilityProducesCorrectJson() dotnetEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); dotnetEntry.GetProperty("type").GetString().Should().Be("product"); dotnetEntry.GetProperty("lifecycle").GetString().Should().Be("ga"); - dotnetEntry.GetProperty("version").GetString().Should().Be("1.5.0"); + dotnetEntry.GetProperty("version").GetString().Should().Be("1.5+"); // Verify apm-agent-node entry var nodeEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "apm-agent-node"); nodeEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); nodeEntry.GetProperty("type").GetString().Should().Be("product"); nodeEntry.GetProperty("lifecycle").GetString().Should().Be("deprecated"); - nodeEntry.GetProperty("version").GetString().Should().Be("2.0.0"); + nodeEntry.GetProperty("version").GetString().Should().Be("2.0+"); } [Fact] @@ -217,7 +217,7 @@ public void SerializeDocumentWithComplexAppliesToProducesCorrectJson() SearchTitle = "Complex Test", Applies = new ApplicableTo { - Stack = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.0.0" }]), + Stack = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (VersionSpec)"8.0.0" }]), Deployment = new DeploymentApplicability { Ess = AppliesCollection.GenerallyAvailable @@ -305,10 +305,10 @@ public void RoundTripDocumentWithAppliesToPreservesData() LastUpdated = DateTimeOffset.Parse("2024-01-15T09:00:00Z", CultureInfo.InvariantCulture), Applies = new ApplicableTo { - Stack = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"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 = (SemVersion)"8.6.0" }]) + Ess = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (VersionSpec)"8.6.0" }]) } }, Headings = ["Introduction", "Getting Started"], @@ -343,9 +343,9 @@ public void SerializeDocumentWithMultipleApplicabilitiesPerTypeProducesMultipleA { Stack = new AppliesCollection( [ - new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.0.0" }, - new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"7.17.0" }, - new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (SemVersion)"7.0.0" } + 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" } ]) } }; diff --git a/tests/authoring/Applicability/ApplicableToComponent.fs b/tests/authoring/Applicability/ApplicableToComponent.fs index 2878c4a95..5619211fd 100644 --- a/tests/authoring/Applicability/ApplicableToComponent.fs +++ b/tests/authoring/Applicability/ApplicableToComponent.fs @@ -142,11 +142,15 @@ stack: ga let ``renders all versions`` () = markdown |> convertsToHtml """

- Stack - + + + + 8.0+ +

@@ -401,7 +405,7 @@ stack: ga 8.8.0, preview 8.1.0 """ [] - let ``renders GA planned when preview exists alongside GA`` () = + let ``renders Preview when GA and Preview both exist for an unreleased entry`` () = markdown |> convertsToHtml """

Stack - - GA planned + + Preview @@ -479,11 +483,14 @@ stack: unavailable let ``renders unavailable`` () = markdown |> convertsToHtml """

- + Stack Unavailable + + 8.0+ + @@ -500,9 +507,12 @@ product: ga let ``renders product all versions`` () = markdown |> convertsToHtml """

- + + + 8.0+ +

@@ -526,7 +536,7 @@ This functionality may be changed or removed in a future release. Elastic will w Preview - 1.3.0 + 1.3+
@@ -694,7 +704,7 @@ stack: let ``renders missing edge cases`` () = markdown |> convertsToHtml """

- Stack @@ -801,6 +811,7 @@ If this functionality is unavailable or behaves differently when deployed on ECH """ // Test multiple lifecycles for same applicability key +// With version inference: ga 8.0, beta 8.1 → ga =8.0 (exact), beta 8.1+ (highest gets GTE) type ``multiple lifecycles same key`` () = static let markdown = Setup.Markdown """ ```{applies_to} @@ -824,7 +835,7 @@ Beta features are subject to change. The design and code is less mature than off GA - 8.0.0 + 8.0 diff --git a/tests/authoring/Applicability/AppliesToFrontMatter.fs b/tests/authoring/Applicability/AppliesToFrontMatter.fs index 2d1f02b95..95483372e 100644 --- a/tests/authoring/Applicability/AppliesToFrontMatter.fs +++ b/tests/authoring/Applicability/AppliesToFrontMatter.fs @@ -163,10 +163,7 @@ applies_to: [] let ``apply matches expected`` () = markdown |> appliesTo (ApplicableTo( - Product=AppliesCollection([ - Applicability.op_Explicit "removed 9.7"; - Applicability.op_Explicit "preview 9.5" - ] |> Array.ofList) + Product=AppliesCollection.op_Explicit "removed 9.7, preview 9.5" )) type ``lenient to defining types at top level`` () = diff --git a/tests/authoring/Blocks/Admonitions.fs b/tests/authoring/Blocks/Admonitions.fs index d7efdb64b..85e738a23 100644 --- a/tests/authoring/Blocks/Admonitions.fs +++ b/tests/authoring/Blocks/Admonitions.fs @@ -64,14 +64,18 @@ This is a custom admonition with applies_to information.

Note - Stack - - - - + + + + 8.0+ + + + +
@@ -82,12 +86,16 @@ If this functionality is unavailable or behaves differently when deployed on ECH
Warning - + Serverless - - - - + + + + 8.0+ + + + +
@@ -98,13 +106,16 @@ If this functionality is unavailable or behaves differently when deployed on ECH
Tip - Serverless Elasticsearch Preview + + 8.0+ + diff --git a/tests/authoring/Inline/AppliesToRole.fs b/tests/authoring/Inline/AppliesToRole.fs index 6583a0d79..e6a3f09a1 100644 --- a/tests/authoring/Inline/AppliesToRole.fs +++ b/tests/authoring/Inline/AppliesToRole.fs @@ -125,7 +125,7 @@ type ``parses multiple applies_to in one line`` () = type ``render 'GA Planned' if preview exists alongside ga`` () = static let markdown = Setup.Markdown """ -This is an inline {applies_to}`stack: preview 9.0, ga 9.1` element. +This is an inline {applies_to}`stack: preview 8.0, ga 8.1` element. """ [] @@ -133,7 +133,7 @@ This is an inline {applies_to}`stack: preview 9.0, ga 9.1` element. let directives = markdown |> converts "index.md" |> parses test <@ directives.Length = 1 @> directives |> appliesToDirective (ApplicableTo( - Stack=AppliesCollection.op_Explicit "ga 9.1, preview 9.0" + Stack=AppliesCollection.op_Explicit "ga 8.1, preview 8.0" )) [] @@ -141,17 +141,20 @@ This is an inline {applies_to}`stack: preview 9.0, ga 9.1` element. markdown |> convertsToHtml """

This is an inline - +If this functionality is unavailable or behaves differently when deployed on ECH, ECE, ECK, or a self-managed installation, it will be indicated on the page.

"> Stack - - GA planned + + Preview + + 8.0 +