Skip to content

Commit a918d06

Browse files
authored
Add dotnetVersion class for version parsing (#50492)
2 parents 060a91b + a09f30f commit a918d06

File tree

1 file changed

+348
-5
lines changed

1 file changed

+348
-5
lines changed
Lines changed: 348 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,356 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
using System;
5-
using System.Collections.Generic;
6-
using System.Text;
4+
using System.Diagnostics;
5+
using Microsoft.Deployment.DotNet.Releases;
76

8-
namespace Microsoft.DotNet.Tools.Bootstrapper
7+
namespace Microsoft.DotNet.Tools.Bootstrapper;
8+
9+
/// <summary>
10+
/// Represents the type of .NET version (SDK or Runtime).
11+
/// </summary>
12+
internal enum DotnetVersionType
13+
{
14+
/// <summary>Automatically detect based on version format.</summary>
15+
Auto,
16+
/// <summary>SDK version (has feature bands, e.g., 8.0.301).</summary>
17+
Sdk,
18+
/// <summary>Runtime version (no feature bands, e.g., 8.0.7).</summary>
19+
Runtime
20+
}
21+
22+
/// <summary>
23+
/// Represents a .NET version string with specialized parsing, comparison, and manipulation capabilities.
24+
/// Acts like a string but provides version-specific operations like feature band extraction and semantic comparisons.
25+
/// Supports both SDK versions (with feature bands) and Runtime versions, and handles build hashes and preview versions.
26+
/// </summary>
27+
[DebuggerDisplay("{Value} ({VersionType})")]
28+
internal readonly record struct DotnetVersion : IComparable<DotnetVersion>, IComparable<string>, IEquatable<string>
929
{
10-
internal class DotnetVersion
30+
private readonly ReleaseVersion? _releaseVersion;
31+
32+
/// <summary>Gets the original version string value.</summary>
33+
public string Value { get; }
34+
35+
/// <summary>Gets the version type (SDK or Runtime).</summary>
36+
public DotnetVersionType VersionType { get; }
37+
38+
/// <summary>Gets the major version component (e.g., "8" from "8.0.301").</summary>
39+
public int Major => _releaseVersion?.Major ?? 0;
40+
41+
/// <summary>Gets the minor version component (e.g., "0" from "8.0.301").</summary>
42+
public int Minor => _releaseVersion?.Minor ?? 0;
43+
44+
/// <summary>Gets the patch version component (e.g., "301" from "8.0.301").</summary>
45+
public int Patch => _releaseVersion?.Patch ?? 0;
46+
47+
/// <summary>Gets the major.minor version string (e.g., "8.0" from "8.0.301").</summary>
48+
public string MajorMinor => $"{Major}.{Minor}";
49+
50+
/// <summary>Gets whether this version represents a preview version (contains '-preview').</summary>
51+
public bool IsPreview => Value.Contains("-preview", StringComparison.OrdinalIgnoreCase);
52+
53+
/// <summary>Gets whether this version represents a prerelease (contains '-' but not just build hash).</summary>
54+
public bool IsPrerelease => Value.Contains('-') && !IsOnlyBuildHash();
55+
56+
/// <summary>Gets whether this is an SDK version (has feature bands).</summary>
57+
public bool IsSdkVersion => VersionType == DotnetVersionType.Sdk ||
58+
(VersionType == DotnetVersionType.Auto && DetectVersionType() == DotnetVersionType.Sdk);
59+
60+
/// <summary>Gets whether this is a Runtime version (no feature bands).</summary>
61+
public bool IsRuntimeVersion => VersionType == DotnetVersionType.Runtime ||
62+
(VersionType == DotnetVersionType.Auto && DetectVersionType() == DotnetVersionType.Runtime);
63+
64+
/// <summary>Gets whether this version contains a build hash.</summary>
65+
public bool HasBuildHash => GetBuildHash() is not null;
66+
67+
/// <summary>Gets whether this version is fully specified (e.g., "8.0.301" vs "8.0" or "8.0.3xx").</summary>
68+
public bool IsFullySpecified => _releaseVersion is not null &&
69+
!Value.Contains('x') &&
70+
Value.Split('.').Length >= 3;
71+
72+
/// <summary>Gets whether this version uses a non-specific feature band pattern (e.g., "8.0.3xx").</summary>
73+
public bool IsNonSpecificFeatureBand => Value.EndsWith('x') && Value.Split('.').Length == 3;
74+
75+
/// <summary>Gets whether this is just a major or major.minor version (e.g., "8" or "8.0").</summary>
76+
public bool IsNonSpecificMajorMinor => Value.Split('.').Length <= 2 &&
77+
Value.Split('.').All(x => int.TryParse(x, out _));
78+
79+
/// <summary>
80+
/// Initializes a new instance with the specified version string.
81+
/// </summary>
82+
/// <param name="value">The version string to parse.</param>
83+
/// <param name="versionType">The type of version (SDK or Runtime). Auto-detects if not specified.</param>
84+
public DotnetVersion(string? value, DotnetVersionType versionType = DotnetVersionType.Auto)
85+
{
86+
Value = value ?? string.Empty;
87+
VersionType = versionType;
88+
_releaseVersion = ReleaseVersion.TryParse(GetVersionWithoutBuildHash(), out var version) ? version : null;
89+
}
90+
91+
/// <summary>
92+
/// Gets the feature band number from the SDK version (e.g., "3" from "8.0.301").
93+
/// Returns null if this is not an SDK version or doesn't contain a feature band.
94+
/// </summary>
95+
public string? GetFeatureBand()
96+
{
97+
if (!IsSdkVersion) return null;
98+
99+
var parts = GetVersionWithoutBuildHash().Split('.');
100+
if (parts.Length < 3) return null;
101+
102+
var patchPart = parts[2].Split('-')[0]; // Remove prerelease suffix
103+
return patchPart.Length > 0 ? patchPart[0].ToString() : null;
104+
}
105+
106+
/// <summary>
107+
/// Gets the feature band patch version (e.g., "01" from "8.0.301").
108+
/// Returns null if this is not an SDK version or doesn't contain a feature band.
109+
/// </summary>
110+
public string? GetFeatureBandPatch()
11111
{
112+
if (!IsSdkVersion) return null;
113+
114+
var parts = GetVersionWithoutBuildHash().Split('.');
115+
if (parts.Length < 3) return null;
116+
117+
var patchPart = parts[2].Split('-')[0]; // Remove prerelease suffix
118+
return patchPart.Length > 1 ? patchPart[1..] : null;
12119
}
120+
121+
/// <summary>
122+
/// Gets the complete feature band including patch (e.g., "301" from "8.0.301").
123+
/// Returns null if this is not an SDK version or doesn't contain a feature band.
124+
/// </summary>
125+
public string? GetCompleteBandAndPatch()
126+
{
127+
if (!IsSdkVersion) return null;
128+
129+
var parts = GetVersionWithoutBuildHash().Split('.');
130+
if (parts.Length < 3) return null;
131+
132+
return parts[2].Split('-')[0]; // Remove prerelease suffix if present
133+
}
134+
135+
/// <summary>
136+
/// Gets the prerelease identifier if this is a prerelease version.
137+
/// </summary>
138+
public string? GetPrereleaseIdentifier()
139+
{
140+
var dashIndex = Value.IndexOf('-');
141+
return dashIndex >= 0 ? Value[(dashIndex + 1)..] : null;
142+
}
143+
144+
/// <summary>
145+
/// Gets the build hash from the version if present (typically after a '+' or at the end of prerelease).
146+
/// Examples: "8.0.301+abc123" -> "abc123", "8.0.301-preview.1.abc123" -> "abc123"
147+
/// </summary>
148+
public string? GetBuildHash()
149+
{
150+
// Build hash after '+'
151+
var plusIndex = Value.IndexOf('+');
152+
if (plusIndex >= 0)
153+
return Value[(plusIndex + 1)..];
154+
155+
// Build hash in prerelease (look for hex-like string at the end)
156+
var prerelease = GetPrereleaseIdentifier();
157+
if (prerelease is null) return null;
158+
159+
var parts = prerelease.Split('.');
160+
var lastPart = parts[^1];
161+
162+
// Check if last part looks like a build hash (hex string, 6+ chars)
163+
if (lastPart.Length >= 6 && lastPart.All(c => char.IsAsciiHexDigit(c)))
164+
return lastPart;
165+
166+
return null;
167+
}
168+
169+
/// <summary>
170+
/// Gets the version string without any build hash component.
171+
/// </summary>
172+
public string GetVersionWithoutBuildHash()
173+
{
174+
var buildHash = GetBuildHash();
175+
if (buildHash is null) return Value;
176+
177+
// Remove build hash after '+'
178+
var plusIndex = Value.IndexOf('+');
179+
if (plusIndex >= 0)
180+
return Value[..plusIndex];
181+
182+
// Remove build hash from prerelease
183+
return Value.Replace($".{buildHash}", "");
184+
}
185+
186+
/// <summary>
187+
/// Detects whether this is an SDK or Runtime version based on the version format.
188+
/// SDK versions typically have 3-digit patch numbers (feature bands), Runtime versions have 1-2 digit patch numbers.
189+
/// </summary>
190+
private DotnetVersionType DetectVersionType()
191+
{
192+
var parts = GetVersionWithoutBuildHash().Split('.', '-');
193+
if (parts.Length < 3) return DotnetVersionType.Runtime;
194+
195+
var patchPart = parts[2];
196+
197+
// SDK versions typically have 3-digit patch numbers (e.g., 301, 201)
198+
// Runtime versions have 1-2 digit patch numbers (e.g., 7, 12)
199+
if (patchPart.Length >= 3 && patchPart.All(char.IsDigit))
200+
return DotnetVersionType.Sdk;
201+
202+
return DotnetVersionType.Runtime;
203+
}
204+
205+
/// <summary>
206+
/// Checks if the version only contains a build hash (no other prerelease identifiers).
207+
/// </summary>
208+
private bool IsOnlyBuildHash()
209+
{
210+
var dashIndex = Value.IndexOf('-');
211+
if (dashIndex < 0) return false;
212+
213+
var afterDash = Value[(dashIndex + 1)..];
214+
215+
// Check if what follows the dash is just a build hash
216+
return afterDash.Length >= 6 && afterDash.All(c => char.IsAsciiHexDigit(c));
217+
}
218+
219+
/// <summary>
220+
/// Creates a new version with the specified patch version while preserving other components.
221+
/// </summary>
222+
public DotnetVersion WithPatch(int patch)
223+
{
224+
var parts = Value.Split('.');
225+
if (parts.Length < 3)
226+
return new DotnetVersion($"{Major}.{Minor}.{patch:D3}");
227+
228+
var prereleaseAndBuild = GetPrereleaseAndBuildSuffix();
229+
return new DotnetVersion($"{Major}.{Minor}.{patch:D3}{prereleaseAndBuild}");
230+
}
231+
232+
/// <summary>
233+
/// Creates a new version with the specified feature band while preserving other components.
234+
/// </summary>
235+
public DotnetVersion WithFeatureBand(int featureBand)
236+
{
237+
var currentPatch = GetFeatureBandPatch();
238+
var patch = $"{featureBand}{currentPatch ?? "00"}";
239+
var prereleaseAndBuild = GetPrereleaseAndBuildSuffix();
240+
return new DotnetVersion($"{Major}.{Minor}.{patch}{prereleaseAndBuild}");
241+
}
242+
243+
private string GetPrereleaseAndBuildSuffix()
244+
{
245+
var dashIndex = Value.IndexOf('-');
246+
return dashIndex >= 0 ? Value[dashIndex..] : string.Empty;
247+
}
248+
249+
/// <summary>
250+
/// Validates that this version string represents a well-formed, fully specified version.
251+
/// </summary>
252+
public bool IsValidFullySpecifiedVersion()
253+
{
254+
if (!IsFullySpecified) return false;
255+
256+
var parts = Value.Split('.', '-')[0].Split('.');
257+
if (parts.Length < 3 || Value.Length > 20) return false;
258+
259+
// Check that patch version is reasonable (1-2 digits for feature band, 1-2 for patch)
260+
return parts.All(p => int.TryParse(p, out _)) && parts[2].Length is >= 2 and <= 3;
261+
}
262+
263+
#region String-like behavior
264+
265+
public static implicit operator string(DotnetVersion version) => version.Value;
266+
public static implicit operator DotnetVersion(string version) => new(version);
267+
268+
/// <summary>
269+
/// Creates an SDK version from a string.
270+
/// </summary>
271+
public static DotnetVersion FromSdk(string version) => new(version, DotnetVersionType.Sdk);
272+
273+
/// <summary>
274+
/// Creates a Runtime version from a string.
275+
/// </summary>
276+
public static DotnetVersion FromRuntime(string version) => new(version, DotnetVersionType.Runtime);
277+
278+
public override string ToString() => Value;
279+
280+
public bool Equals(string? other) => string.Equals(Value, other, StringComparison.Ordinal);
281+
282+
#endregion
283+
284+
#region IComparable implementations
285+
286+
public int CompareTo(DotnetVersion other)
287+
{
288+
// Use semantic version comparison if both are valid release versions
289+
if (_releaseVersion is not null && other._releaseVersion is not null)
290+
return _releaseVersion.CompareTo(other._releaseVersion);
291+
292+
// Fall back to string comparison
293+
return string.Compare(Value, other.Value, StringComparison.Ordinal);
294+
}
295+
296+
public int CompareTo(string? other)
297+
{
298+
if (other is null) return 1;
299+
return CompareTo(new DotnetVersion(other));
300+
}
301+
302+
#endregion
303+
304+
#region Static utility methods
305+
306+
/// <summary>
307+
/// Determines whether the specified string represents a valid .NET version format.
308+
/// </summary>
309+
public static bool IsValidFormat(string? value)
310+
{
311+
if (string.IsNullOrWhiteSpace(value)) return false;
312+
return new DotnetVersion(value).IsValidFullySpecifiedVersion() ||
313+
new DotnetVersion(value).IsNonSpecificFeatureBand ||
314+
new DotnetVersion(value).IsNonSpecificMajorMinor;
315+
}
316+
317+
/// <summary>
318+
/// Tries to parse a version string into a DotnetVersion.
319+
/// </summary>
320+
/// <param name="value">The version string to parse.</param>
321+
/// <param name="version">The parsed version if successful.</param>
322+
/// <param name="versionType">The type of version to parse. Auto-detects if not specified.</param>
323+
public static bool TryParse(string? value, out DotnetVersion version, DotnetVersionType versionType = DotnetVersionType.Auto)
324+
{
325+
version = new DotnetVersion(value, versionType);
326+
return IsValidFormat(value);
327+
}
328+
329+
/// <summary>
330+
/// Parses a version string into a DotnetVersion, throwing on invalid format.
331+
/// </summary>
332+
/// <param name="value">The version string to parse.</param>
333+
/// <param name="versionType">The type of version to parse. Auto-detects if not specified.</param>
334+
public static DotnetVersion Parse(string value, DotnetVersionType versionType = DotnetVersionType.Auto)
335+
{
336+
if (!TryParse(value, out var version, versionType))
337+
throw new ArgumentException($"'{value}' is not a valid .NET version format.", nameof(value));
338+
return version;
339+
}
340+
341+
#endregion
342+
343+
#region String comparison operators
344+
345+
public static bool operator <(DotnetVersion left, DotnetVersion right) => left.CompareTo(right) < 0;
346+
public static bool operator <=(DotnetVersion left, DotnetVersion right) => left.CompareTo(right) <= 0;
347+
public static bool operator >(DotnetVersion left, DotnetVersion right) => left.CompareTo(right) > 0;
348+
public static bool operator >=(DotnetVersion left, DotnetVersion right) => left.CompareTo(right) >= 0;
349+
350+
public static bool operator ==(DotnetVersion left, string? right) => left.Equals(right);
351+
public static bool operator !=(DotnetVersion left, string? right) => !left.Equals(right);
352+
public static bool operator ==(string? left, DotnetVersion right) => right.Equals(left);
353+
public static bool operator !=(string? left, DotnetVersion right) => !right.Equals(left);
354+
355+
#endregion
13356
}

0 commit comments

Comments
 (0)