diff --git a/src/Installer/dnup/DotnetVersion.cs b/src/Installer/dnup/DotnetVersion.cs index fa438537d83a..c25561ceac16 100644 --- a/src/Installer/dnup/DotnetVersion.cs +++ b/src/Installer/dnup/DotnetVersion.cs @@ -1,13 +1,356 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Text; +using System.Diagnostics; +using Microsoft.Deployment.DotNet.Releases; -namespace Microsoft.DotNet.Tools.Bootstrapper +namespace Microsoft.DotNet.Tools.Bootstrapper; + +/// +/// Represents the type of .NET version (SDK or Runtime). +/// +internal enum DotnetVersionType +{ + /// Automatically detect based on version format. + Auto, + /// SDK version (has feature bands, e.g., 8.0.301). + Sdk, + /// Runtime version (no feature bands, e.g., 8.0.7). + Runtime +} + +/// +/// Represents a .NET version string with specialized parsing, comparison, and manipulation capabilities. +/// Acts like a string but provides version-specific operations like feature band extraction and semantic comparisons. +/// Supports both SDK versions (with feature bands) and Runtime versions, and handles build hashes and preview versions. +/// +[DebuggerDisplay("{Value} ({VersionType})")] +internal readonly record struct DotnetVersion : IComparable, IComparable, IEquatable { - internal class DotnetVersion + private readonly ReleaseVersion? _releaseVersion; + + /// Gets the original version string value. + public string Value { get; } + + /// Gets the version type (SDK or Runtime). + public DotnetVersionType VersionType { get; } + + /// Gets the major version component (e.g., "8" from "8.0.301"). + public int Major => _releaseVersion?.Major ?? 0; + + /// Gets the minor version component (e.g., "0" from "8.0.301"). + public int Minor => _releaseVersion?.Minor ?? 0; + + /// Gets the patch version component (e.g., "301" from "8.0.301"). + public int Patch => _releaseVersion?.Patch ?? 0; + + /// Gets the major.minor version string (e.g., "8.0" from "8.0.301"). + public string MajorMinor => $"{Major}.{Minor}"; + + /// Gets whether this version represents a preview version (contains '-preview'). + public bool IsPreview => Value.Contains("-preview", StringComparison.OrdinalIgnoreCase); + + /// Gets whether this version represents a prerelease (contains '-' but not just build hash). + public bool IsPrerelease => Value.Contains('-') && !IsOnlyBuildHash(); + + /// Gets whether this is an SDK version (has feature bands). + public bool IsSdkVersion => VersionType == DotnetVersionType.Sdk || + (VersionType == DotnetVersionType.Auto && DetectVersionType() == DotnetVersionType.Sdk); + + /// Gets whether this is a Runtime version (no feature bands). + public bool IsRuntimeVersion => VersionType == DotnetVersionType.Runtime || + (VersionType == DotnetVersionType.Auto && DetectVersionType() == DotnetVersionType.Runtime); + + /// Gets whether this version contains a build hash. + public bool HasBuildHash => GetBuildHash() is not null; + + /// Gets whether this version is fully specified (e.g., "8.0.301" vs "8.0" or "8.0.3xx"). + public bool IsFullySpecified => _releaseVersion is not null && + !Value.Contains('x') && + Value.Split('.').Length >= 3; + + /// Gets whether this version uses a non-specific feature band pattern (e.g., "8.0.3xx"). + public bool IsNonSpecificFeatureBand => Value.EndsWith('x') && Value.Split('.').Length == 3; + + /// Gets whether this is just a major or major.minor version (e.g., "8" or "8.0"). + public bool IsNonSpecificMajorMinor => Value.Split('.').Length <= 2 && + Value.Split('.').All(x => int.TryParse(x, out _)); + + /// + /// Initializes a new instance with the specified version string. + /// + /// The version string to parse. + /// The type of version (SDK or Runtime). Auto-detects if not specified. + public DotnetVersion(string? value, DotnetVersionType versionType = DotnetVersionType.Auto) + { + Value = value ?? string.Empty; + VersionType = versionType; + _releaseVersion = ReleaseVersion.TryParse(GetVersionWithoutBuildHash(), out var version) ? version : null; + } + + /// + /// Gets the feature band number from the SDK version (e.g., "3" from "8.0.301"). + /// Returns null if this is not an SDK version or doesn't contain a feature band. + /// + public string? GetFeatureBand() + { + if (!IsSdkVersion) return null; + + var parts = GetVersionWithoutBuildHash().Split('.'); + if (parts.Length < 3) return null; + + var patchPart = parts[2].Split('-')[0]; // Remove prerelease suffix + return patchPart.Length > 0 ? patchPart[0].ToString() : null; + } + + /// + /// Gets the feature band patch version (e.g., "01" from "8.0.301"). + /// Returns null if this is not an SDK version or doesn't contain a feature band. + /// + public string? GetFeatureBandPatch() { + if (!IsSdkVersion) return null; + + var parts = GetVersionWithoutBuildHash().Split('.'); + if (parts.Length < 3) return null; + + var patchPart = parts[2].Split('-')[0]; // Remove prerelease suffix + return patchPart.Length > 1 ? patchPart[1..] : null; } + + /// + /// Gets the complete feature band including patch (e.g., "301" from "8.0.301"). + /// Returns null if this is not an SDK version or doesn't contain a feature band. + /// + public string? GetCompleteBandAndPatch() + { + if (!IsSdkVersion) return null; + + var parts = GetVersionWithoutBuildHash().Split('.'); + if (parts.Length < 3) return null; + + return parts[2].Split('-')[0]; // Remove prerelease suffix if present + } + + /// + /// Gets the prerelease identifier if this is a prerelease version. + /// + public string? GetPrereleaseIdentifier() + { + var dashIndex = Value.IndexOf('-'); + return dashIndex >= 0 ? Value[(dashIndex + 1)..] : null; + } + + /// + /// Gets the build hash from the version if present (typically after a '+' or at the end of prerelease). + /// Examples: "8.0.301+abc123" -> "abc123", "8.0.301-preview.1.abc123" -> "abc123" + /// + public string? GetBuildHash() + { + // Build hash after '+' + var plusIndex = Value.IndexOf('+'); + if (plusIndex >= 0) + return Value[(plusIndex + 1)..]; + + // Build hash in prerelease (look for hex-like string at the end) + var prerelease = GetPrereleaseIdentifier(); + if (prerelease is null) return null; + + var parts = prerelease.Split('.'); + var lastPart = parts[^1]; + + // Check if last part looks like a build hash (hex string, 6+ chars) + if (lastPart.Length >= 6 && lastPart.All(c => char.IsAsciiHexDigit(c))) + return lastPart; + + return null; + } + + /// + /// Gets the version string without any build hash component. + /// + public string GetVersionWithoutBuildHash() + { + var buildHash = GetBuildHash(); + if (buildHash is null) return Value; + + // Remove build hash after '+' + var plusIndex = Value.IndexOf('+'); + if (plusIndex >= 0) + return Value[..plusIndex]; + + // Remove build hash from prerelease + return Value.Replace($".{buildHash}", ""); + } + + /// + /// Detects whether this is an SDK or Runtime version based on the version format. + /// SDK versions typically have 3-digit patch numbers (feature bands), Runtime versions have 1-2 digit patch numbers. + /// + private DotnetVersionType DetectVersionType() + { + var parts = GetVersionWithoutBuildHash().Split('.', '-'); + if (parts.Length < 3) return DotnetVersionType.Runtime; + + var patchPart = parts[2]; + + // SDK versions typically have 3-digit patch numbers (e.g., 301, 201) + // Runtime versions have 1-2 digit patch numbers (e.g., 7, 12) + if (patchPart.Length >= 3 && patchPart.All(char.IsDigit)) + return DotnetVersionType.Sdk; + + return DotnetVersionType.Runtime; + } + + /// + /// Checks if the version only contains a build hash (no other prerelease identifiers). + /// + private bool IsOnlyBuildHash() + { + var dashIndex = Value.IndexOf('-'); + if (dashIndex < 0) return false; + + var afterDash = Value[(dashIndex + 1)..]; + + // Check if what follows the dash is just a build hash + return afterDash.Length >= 6 && afterDash.All(c => char.IsAsciiHexDigit(c)); + } + + /// + /// Creates a new version with the specified patch version while preserving other components. + /// + public DotnetVersion WithPatch(int patch) + { + var parts = Value.Split('.'); + if (parts.Length < 3) + return new DotnetVersion($"{Major}.{Minor}.{patch:D3}"); + + var prereleaseAndBuild = GetPrereleaseAndBuildSuffix(); + return new DotnetVersion($"{Major}.{Minor}.{patch:D3}{prereleaseAndBuild}"); + } + + /// + /// Creates a new version with the specified feature band while preserving other components. + /// + public DotnetVersion WithFeatureBand(int featureBand) + { + var currentPatch = GetFeatureBandPatch(); + var patch = $"{featureBand}{currentPatch ?? "00"}"; + var prereleaseAndBuild = GetPrereleaseAndBuildSuffix(); + return new DotnetVersion($"{Major}.{Minor}.{patch}{prereleaseAndBuild}"); + } + + private string GetPrereleaseAndBuildSuffix() + { + var dashIndex = Value.IndexOf('-'); + return dashIndex >= 0 ? Value[dashIndex..] : string.Empty; + } + + /// + /// Validates that this version string represents a well-formed, fully specified version. + /// + public bool IsValidFullySpecifiedVersion() + { + if (!IsFullySpecified) return false; + + var parts = Value.Split('.', '-')[0].Split('.'); + if (parts.Length < 3 || Value.Length > 20) return false; + + // Check that patch version is reasonable (1-2 digits for feature band, 1-2 for patch) + return parts.All(p => int.TryParse(p, out _)) && parts[2].Length is >= 2 and <= 3; + } + + #region String-like behavior + + public static implicit operator string(DotnetVersion version) => version.Value; + public static implicit operator DotnetVersion(string version) => new(version); + + /// + /// Creates an SDK version from a string. + /// + public static DotnetVersion FromSdk(string version) => new(version, DotnetVersionType.Sdk); + + /// + /// Creates a Runtime version from a string. + /// + public static DotnetVersion FromRuntime(string version) => new(version, DotnetVersionType.Runtime); + + public override string ToString() => Value; + + public bool Equals(string? other) => string.Equals(Value, other, StringComparison.Ordinal); + + #endregion + + #region IComparable implementations + + public int CompareTo(DotnetVersion other) + { + // Use semantic version comparison if both are valid release versions + if (_releaseVersion is not null && other._releaseVersion is not null) + return _releaseVersion.CompareTo(other._releaseVersion); + + // Fall back to string comparison + return string.Compare(Value, other.Value, StringComparison.Ordinal); + } + + public int CompareTo(string? other) + { + if (other is null) return 1; + return CompareTo(new DotnetVersion(other)); + } + + #endregion + + #region Static utility methods + + /// + /// Determines whether the specified string represents a valid .NET version format. + /// + public static bool IsValidFormat(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + return new DotnetVersion(value).IsValidFullySpecifiedVersion() || + new DotnetVersion(value).IsNonSpecificFeatureBand || + new DotnetVersion(value).IsNonSpecificMajorMinor; + } + + /// + /// Tries to parse a version string into a DotnetVersion. + /// + /// The version string to parse. + /// The parsed version if successful. + /// The type of version to parse. Auto-detects if not specified. + public static bool TryParse(string? value, out DotnetVersion version, DotnetVersionType versionType = DotnetVersionType.Auto) + { + version = new DotnetVersion(value, versionType); + return IsValidFormat(value); + } + + /// + /// Parses a version string into a DotnetVersion, throwing on invalid format. + /// + /// The version string to parse. + /// The type of version to parse. Auto-detects if not specified. + public static DotnetVersion Parse(string value, DotnetVersionType versionType = DotnetVersionType.Auto) + { + if (!TryParse(value, out var version, versionType)) + throw new ArgumentException($"'{value}' is not a valid .NET version format.", nameof(value)); + return version; + } + + #endregion + + #region String comparison operators + + public static bool operator <(DotnetVersion left, DotnetVersion right) => left.CompareTo(right) < 0; + public static bool operator <=(DotnetVersion left, DotnetVersion right) => left.CompareTo(right) <= 0; + public static bool operator >(DotnetVersion left, DotnetVersion right) => left.CompareTo(right) > 0; + public static bool operator >=(DotnetVersion left, DotnetVersion right) => left.CompareTo(right) >= 0; + + public static bool operator ==(DotnetVersion left, string? right) => left.Equals(right); + public static bool operator !=(DotnetVersion left, string? right) => !left.Equals(right); + public static bool operator ==(string? left, DotnetVersion right) => right.Equals(left); + public static bool operator !=(string? left, DotnetVersion right) => !right.Equals(left); + + #endregion }