Skip to content

Commit ce13ed4

Browse files
CopilotMalcolmnixon
andcommitted
Refactor TagInfo to record with primary constructor, static factory, and compile-time regex parsing
Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com>
1 parent 5646c40 commit ce13ed4

File tree

7 files changed

+88
-90
lines changed

7 files changed

+88
-90
lines changed

src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,12 @@ public override async Task<List<TagInfo>> GetTagHistoryAsync()
8282
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
8383
.Select(t => t.Trim())
8484
.ToList();
85-
return tagNames.Select(t => new TagInfo(t)).ToList();
85+
// Filter out non-version tags
86+
return tagNames
87+
.Select(TagInfo.Create)
88+
.Where(t => t != null)
89+
.Cast<TagInfo>()
90+
.ToList();
8691
}
8792

8893
/// <summary>

src/DemaConsulting.BuildMark/RepoConnectors/MockRepoConnector.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,12 @@ public class MockRepoConnector : IRepoConnector
6767
/// <returns>List of tags in chronological order.</returns>
6868
public Task<List<TagInfo>> GetTagHistoryAsync()
6969
{
70-
// Use dictionary keys to avoid duplication
71-
var tagInfoList = _tagHashes.Keys.Select(t => new TagInfo(t)).ToList();
70+
// Use dictionary keys to avoid duplication, filter out non-version tags
71+
var tagInfoList = _tagHashes.Keys
72+
.Select(TagInfo.Create)
73+
.Where(t => t != null)
74+
.Cast<TagInfo>()
75+
.ToList();
7276
return Task.FromResult(tagInfoList);
7377
}
7478

src/DemaConsulting.BuildMark/TagInfo.cs

Lines changed: 34 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -18,77 +18,55 @@
1818
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
1919
// SOFTWARE.
2020

21+
using System.Text.RegularExpressions;
22+
2123
namespace DemaConsulting.BuildMark;
2224

2325
/// <summary>
2426
/// Represents a version tag with parsed semantic version information.
2527
/// </summary>
26-
public class TagInfo
28+
/// <param name="Tag">The tag name.</param>
29+
/// <param name="FullVersion">The full semantic version (major.minor.patch-prerelease) with leading non-version characters removed.</param>
30+
/// <param name="IsPreRelease">Whether this is a pre-release version.</param>
31+
public partial record TagInfo(string Tag, string FullVersion, bool IsPreRelease)
2732
{
2833
/// <summary>
29-
/// Initializes a new instance of the <see cref="TagInfo"/> class.
30-
/// </summary>
31-
/// <param name="tagName">Tag name.</param>
32-
public TagInfo(string tagName)
33-
{
34-
Tag = tagName;
35-
FullVersion = ParseVersion(tagName);
36-
IsPreRelease = DetectPreRelease(FullVersion);
37-
}
38-
39-
/// <summary>
40-
/// Gets the tag name.
41-
/// </summary>
42-
public string Tag { get; }
43-
44-
/// <summary>
45-
/// Gets the full semantic version (major.minor.patch-prerelease) with leading non-version characters removed.
46-
/// </summary>
47-
public string FullVersion { get; }
48-
49-
/// <summary>
50-
/// Gets a value indicating whether this is a pre-release version.
34+
/// Regex pattern for parsing version tags with optional prefix and pre-release suffix.
5135
/// </summary>
52-
public bool IsPreRelease { get; }
36+
/// <returns>Compiled regex for parsing tags.</returns>
37+
[GeneratedRegex(@"^(?:[a-zA-Z_-]+)?(?<version>\d+\.\d+\.\d+)(?:-(?<pre_release>[a-zA-Z0-9.-]+))?(?:\.(?<metadata>[a-zA-Z0-9.-]+))?$")]
38+
private static partial Regex TagPattern();
5339

5440
/// <summary>
55-
/// Parses a tag name and extracts the semantic version by removing leading non-numeric characters.
41+
/// Creates a TagInfo from a tag string, or returns null if the tag doesn't match version format.
5642
/// </summary>
57-
/// <param name="tagName">Tag name to parse.</param>
58-
/// <returns>Semantic version string.</returns>
59-
private static string ParseVersion(string tagName)
43+
/// <param name="tag">Tag name to parse.</param>
44+
/// <returns>TagInfo instance if tag matches version format, null otherwise.</returns>
45+
public static TagInfo? Create(string tag)
6046
{
61-
// Remove any leading alphabetic characters, dashes, and underscores
62-
// This supports various tag naming conventions like "v1.0.0", "ver-1.0.0", "release_1.0.0", etc.
63-
var startIndex = 0;
64-
while (startIndex < tagName.Length)
47+
var match = TagPattern().Match(tag);
48+
if (!match.Success)
6549
{
66-
var c = tagName[startIndex];
67-
if (char.IsDigit(c))
68-
{
69-
break;
70-
}
71-
72-
if (c != '-' && c != '_' && !char.IsLetter(c))
73-
{
74-
break;
75-
}
76-
77-
startIndex++;
50+
return null;
7851
}
7952

80-
return startIndex < tagName.Length ? tagName[startIndex..] : tagName;
81-
}
53+
var version = match.Groups["version"].Value;
54+
var preRelease = match.Groups["pre_release"];
55+
var metadata = match.Groups["metadata"];
56+
57+
// Build full version: version + optional pre-release + optional metadata
58+
var fullVersion = version;
59+
if (preRelease.Success)
60+
{
61+
fullVersion += $"-{preRelease.Value}";
62+
}
63+
if (metadata.Success)
64+
{
65+
fullVersion += $".{metadata.Value}";
66+
}
67+
68+
var isPreRelease = preRelease.Success;
8269

83-
/// <summary>
84-
/// Detects if a version string represents a pre-release.
85-
/// </summary>
86-
/// <param name="version">Version string to check.</param>
87-
/// <returns>True if the version is a pre-release, false otherwise.</returns>
88-
private static bool DetectPreRelease(string version)
89-
{
90-
// Check if the version contains a hyphen followed by any text
91-
// This covers semantic versioning pre-release format (e.g., 1.0.0-alpha, 1.0.0-beta.1, 1.0.0-rc.1)
92-
return version.Contains('-');
70+
return new TagInfo(tag, fullVersion, isPreRelease);
9371
}
9472
}

test/DemaConsulting.BuildMark.Tests/BuildInformationTests.cs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ public async Task BuildInformation_CreateAsync_WorksWithExplicitVersion()
6868
var connector = new MockRepoConnector();
6969

7070
// Act
71-
var buildInfo = await BuildInformation.CreateAsync(connector, new TagInfo("v2.1.0"));
71+
var buildInfo = await BuildInformation.CreateAsync(connector, TagInfo.Create("v2.1.0")!);
7272

7373
// Assert
7474
Assert.AreEqual("v2.1.0", buildInfo.ToVersion);
@@ -105,7 +105,7 @@ public async Task BuildInformation_CreateAsync_PreReleaseUsesPreviousTag()
105105
var connector = new MockRepoConnector();
106106

107107
// Act
108-
var buildInfo = await BuildInformation.CreateAsync(connector, new TagInfo("v2.0.0-beta.1"));
108+
var buildInfo = await BuildInformation.CreateAsync(connector, TagInfo.Create("v2.0.0-beta.1")!);
109109

110110
// Assert
111111
Assert.AreEqual("v2.0.0-beta.1", buildInfo.ToVersion);
@@ -122,7 +122,7 @@ public async Task BuildInformation_CreateAsync_ReleaseSkipsPreReleases()
122122
var connector = new MockRepoConnector();
123123

124124
// Act
125-
var buildInfo = await BuildInformation.CreateAsync(connector, new TagInfo("v2.0.0"));
125+
var buildInfo = await BuildInformation.CreateAsync(connector, TagInfo.Create("v2.0.0")!);
126126

127127
// Assert
128128
Assert.AreEqual("2.0.0", buildInfo.ToVersion);
@@ -139,7 +139,7 @@ public async Task BuildInformation_CreateAsync_CollectsIssuesCorrectly()
139139
var connector = new MockRepoConnector();
140140

141141
// Act
142-
var buildInfo = await BuildInformation.CreateAsync(connector, new TagInfo("ver-1.1.0"));
142+
var buildInfo = await BuildInformation.CreateAsync(connector, TagInfo.Create("ver-1.1.0")!);
143143

144144
// Assert
145145
Assert.HasCount(1, buildInfo.ChangeIssues);
@@ -165,7 +165,7 @@ public async Task BuildInformation_CreateAsync_SeparatesBugAndChangeIssues()
165165
var connector = new MockRepoConnector();
166166

167167
// Act
168-
var buildInfo = await BuildInformation.CreateAsync(connector, new TagInfo("v2.0.0"));
168+
var buildInfo = await BuildInformation.CreateAsync(connector, TagInfo.Create("v2.0.0")!);
169169

170170
// Assert
171171
Assert.HasCount(1, buildInfo.ChangeIssues);
@@ -184,7 +184,7 @@ public async Task BuildInformation_CreateAsync_HandlesFirstReleaseCorrectly()
184184
var connector = new MockRepoConnector();
185185

186186
// Act
187-
var buildInfo = await BuildInformation.CreateAsync(connector, new TagInfo("v1.0.0"));
187+
var buildInfo = await BuildInformation.CreateAsync(connector, TagInfo.Create("v1.0.0")!);
188188

189189
// Assert
190190
Assert.IsNull(buildInfo.FromVersion);
@@ -213,7 +213,7 @@ private class MockRepoConnectorEmpty : IRepoConnector
213213
private class MockRepoConnectorMismatch : IRepoConnector
214214
{
215215
public Task<List<TagInfo>> GetTagHistoryAsync() =>
216-
Task.FromResult(new List<TagInfo> { new TagInfo("v1.0.0") });
216+
Task.FromResult(new List<TagInfo> { TagInfo.Create("v1.0.0")! });
217217
public Task<List<string>> GetPullRequestsBetweenTagsAsync(TagInfo? fromTag, TagInfo? toTag) => Task.FromResult(new List<string>());
218218
public Task<List<string>> GetIssuesForPullRequestAsync(string pullRequestId) => Task.FromResult(new List<string>());
219219
public Task<string> GetIssueTitleAsync(string issueId) => Task.FromResult("Title");
@@ -231,9 +231,9 @@ private class MockRepoConnectorMatchingTag : IRepoConnector
231231
public Task<List<TagInfo>> GetTagHistoryAsync() =>
232232
Task.FromResult(new List<TagInfo>
233233
{
234-
new TagInfo("v1.0.0"),
235-
new TagInfo("ver-1.1.0"),
236-
new TagInfo("v2.0.0")
234+
TagInfo.Create("v1.0.0")!,
235+
TagInfo.Create("ver-1.1.0")!,
236+
TagInfo.Create("2.0.0")!
237237
});
238238
public Task<List<string>> GetPullRequestsBetweenTagsAsync(TagInfo? fromTag, TagInfo? toTag) => Task.FromResult(new List<string>());
239239
public Task<List<string>> GetIssuesForPullRequestAsync(string pullRequestId) => Task.FromResult(new List<string>());

test/DemaConsulting.BuildMark.Tests/GitHubRepoConnectorTests.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ public async Task GitHubRepoConnector_GetPullRequestsBetweenTagsAsync_ReturnsExp
113113
"abc123 Merge pull request #10 from feature/x\ndef456 Merge pull request #11 from bugfix/y");
114114

115115
// Act
116-
var prs = await connector.GetPullRequestsBetweenTagsAsync(new TagInfo("v1.0.0"), new TagInfo("v2.0.0"));
116+
var prs = await connector.GetPullRequestsBetweenTagsAsync(TagInfo.Create("v1.0.0")!, TagInfo.Create("v2.0.0")!);
117117

118118
// Assert
119119
Assert.HasCount(2, prs);
@@ -135,7 +135,7 @@ public async Task GitHubRepoConnector_GetPullRequestsBetweenTagsAsync_HandlesNul
135135
"abc123 Merge pull request #10 from feature/x");
136136

137137
// Act
138-
var prs = await connector.GetPullRequestsBetweenTagsAsync(null, new TagInfo("v1.0.0"));
138+
var prs = await connector.GetPullRequestsBetweenTagsAsync(null, TagInfo.Create("v1.0.0")!);
139139

140140
// Assert
141141
Assert.HasCount(1, prs);
@@ -156,7 +156,7 @@ public async Task GitHubRepoConnector_GetPullRequestsBetweenTagsAsync_HandlesNul
156156
"abc123 Merge pull request #11 from feature/y");
157157

158158
// Act
159-
var prs = await connector.GetPullRequestsBetweenTagsAsync(new TagInfo("v1.0.0"), null);
159+
var prs = await connector.GetPullRequestsBetweenTagsAsync(TagInfo.Create("v1.0.0")!, null);
160160

161161
// Assert
162162
Assert.HasCount(1, prs);
@@ -305,7 +305,7 @@ public async Task GitHubRepoConnector_GetHashForTagAsync_ReturnsExpectedHash()
305305
connector.AddCommandResult("git", "rev-parse v1.0.0", "abc123def456789");
306306

307307
// Act
308-
var hash = await connector.GetHashForTagAsync(new TagInfo("v1.0.0"));
308+
var hash = await connector.GetHashForTagAsync(TagInfo.Create("v1.0.0")!);
309309

310310
// Assert
311311
Assert.AreEqual("abc123def456789", hash);
@@ -339,7 +339,7 @@ public async Task GitHubRepoConnector_GetPullRequestsBetweenTagsAsync_ThrowsForI
339339

340340
// Act & Assert
341341
var ex = await Assert.ThrowsAsync<ArgumentException>(
342-
async () => await connector.GetPullRequestsBetweenTagsAsync(new TagInfo("v1.0.0; rm -rf /"), null));
342+
async () => await connector.GetPullRequestsBetweenTagsAsync(TagInfo.Create("v1.0.0; rm -rf /")!, null));
343343
Assert.Contains("Invalid tag name", ex.Message);
344344
}
345345

@@ -399,7 +399,7 @@ public async Task GitHubRepoConnector_GetHashForTagAsync_ThrowsForInvalidTagName
399399

400400
// Act & Assert
401401
var ex = await Assert.ThrowsAsync<ArgumentException>(
402-
async () => await connector.GetHashForTagAsync(new TagInfo("v1.0.0 | echo pwned")));
402+
async () => await connector.GetHashForTagAsync(TagInfo.Create("v1.0.0 | echo pwned")!));
403403
Assert.Contains("Invalid tag name", ex.Message);
404404
}
405405
}

test/DemaConsulting.BuildMark.Tests/MockRepoConnectorTests.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ public async Task MockRepoConnector_GetPullRequestsBetweenTagsAsync_ReturnsExpec
5757
var connector = new MockRepoConnector();
5858

5959
// Act
60-
var prs = await connector.GetPullRequestsBetweenTagsAsync(new TagInfo("v1.0.0"), new TagInfo("ver-1.1.0"));
60+
var prs = await connector.GetPullRequestsBetweenTagsAsync(TagInfo.Create("v1.0.0")!, TagInfo.Create("ver-1.1.0")!);
6161

6262
// Assert
6363
Assert.HasCount(1, prs);
@@ -74,7 +74,7 @@ public async Task MockRepoConnector_GetPullRequestsBetweenTagsAsync_ReturnsExpec
7474
var connector = new MockRepoConnector();
7575

7676
// Act
77-
var prs = await connector.GetPullRequestsBetweenTagsAsync(new TagInfo("ver-1.1.0"), new TagInfo("2.0.0"));
77+
var prs = await connector.GetPullRequestsBetweenTagsAsync(TagInfo.Create("ver-1.1.0")!, TagInfo.Create("2.0.0")!);
7878

7979
// Assert
8080
Assert.HasCount(2, prs);
@@ -205,7 +205,7 @@ public async Task MockRepoConnector_GetHashForTagAsync_ReturnsExpectedHash()
205205
var connector = new MockRepoConnector();
206206

207207
// Act
208-
var hash = await connector.GetHashForTagAsync(new TagInfo("v1.0.0"));
208+
var hash = await connector.GetHashForTagAsync(TagInfo.Create("v1.0.0")!);
209209

210210
// Assert
211211
Assert.AreEqual("abc123def456", hash);
@@ -237,7 +237,7 @@ public async Task MockRepoConnector_GetHashForTagAsync_ReturnsUnknownHashForUnkn
237237
var connector = new MockRepoConnector();
238238

239239
// Act
240-
var hash = await connector.GetHashForTagAsync(new TagInfo("v999.0.0"));
240+
var hash = await connector.GetHashForTagAsync(TagInfo.Create("v999.0.0")!);
241241

242242
// Assert
243243
Assert.AreEqual("unknown000hash000", hash);

0 commit comments

Comments
 (0)