diff --git a/Lombiq.HelpfulLibraries.Common/Docs/Utilities.md b/Lombiq.HelpfulLibraries.Common/Docs/Utilities.md
index e51a3d59..f4246ff7 100644
--- a/Lombiq.HelpfulLibraries.Common/Docs/Utilities.md
+++ b/Lombiq.HelpfulLibraries.Common/Docs/Utilities.md
@@ -8,4 +8,5 @@
- `Sha256Helper`: A static helper class with the `ComputeHash()` utility function that converts text into [SHA-256](https://en.wikipedia.org/wiki/SHA-256) hash string.
- `StringHelper`: A static helper class with utility functions for concatenating and generating strings, particularly in a culture invariant manner.
- `Union`: A container type which is a union of two different types. Only either of the two types can be set at the same time.
+- `VersionTree`: A read-only `Version` collection that organizes the provided versions into a tree structure by version parts. Can be used to resolve a floating (partial) version from a collection of specific versions.
- `XmlHelper`: Utility class to streamline XML serialization/deserialization.
diff --git a/Lombiq.HelpfulLibraries.Common/Utilities/VersionTree.cs b/Lombiq.HelpfulLibraries.Common/Utilities/VersionTree.cs
new file mode 100644
index 00000000..3519eed6
--- /dev/null
+++ b/Lombiq.HelpfulLibraries.Common/Utilities/VersionTree.cs
@@ -0,0 +1,64 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Lombiq.HelpfulLibraries.Common.Utilities;
+
+///
+/// Groups instances into a tree structure based on the specified version parts, so they can be
+/// selected by number using indexing.
+///
+/// All versions in the current subtree.
+/// Versions grouped by the directly next version part.
+public record VersionTree(IReadOnlyList Versions, IReadOnlyDictionary SubVersions)
+{
+ ///
+ /// Gets the subtree for the provided key, if it exists (otherwise ). If the is negative, this instance is returned instead.
+ ///
+ public VersionTree? this[int index]
+ {
+ get
+ {
+ if (index < 0) return this;
+ return SubVersions.TryGetValue(index, out var sub) ? sub : null;
+ }
+ }
+
+ ///
+ /// Gets all versions that match the provided , that can be partial/floating.
+ ///
+ public VersionTree? this[Version? version] =>
+ version is null
+ ? this
+ : this[version.Major]?[version.Minor]?[version.Build]?[version.Revision];
+
+ ///
+ /// Creates a new tree from a copy of the provided .
+ ///
+ public static VersionTree Create(IEnumerable versions)
+ {
+ var allVersions = versions.ToList();
+
+ var majorVersions =
+ FromVersions(allVersions, version => version.Major, major =>
+ FromVersions(major, version => version.Minor, minor =>
+ FromVersions(minor, version => version.Build, revision =>
+ FromVersions(revision, version => version.Revision, _ => []))));
+
+ return new(allVersions, majorVersions);
+ }
+
+ private static Dictionary FromVersions(
+ IEnumerable versions,
+ Func selector,
+ Func, Dictionary> subSelector) =>
+ versions
+ .Where(version => selector(version) >= 0)
+ .GroupBy(selector)
+ .ToDictionary(
+ group => group.Key,
+ items => new VersionTree([.. items], subSelector(items)));
+}
diff --git a/Lombiq.HelpfulLibraries.Tests/Lombiq.HelpfulLibraries.Tests.csproj b/Lombiq.HelpfulLibraries.Tests/Lombiq.HelpfulLibraries.Tests.csproj
index 834c188c..cb43f4af 100644
--- a/Lombiq.HelpfulLibraries.Tests/Lombiq.HelpfulLibraries.Tests.csproj
+++ b/Lombiq.HelpfulLibraries.Tests/Lombiq.HelpfulLibraries.Tests.csproj
@@ -34,4 +34,10 @@
+
+
+ PreserveNewest
+
+
+
diff --git a/Lombiq.HelpfulLibraries.Tests/UnitTests/Utilities/VersionTreeTests.cs b/Lombiq.HelpfulLibraries.Tests/UnitTests/Utilities/VersionTreeTests.cs
new file mode 100644
index 00000000..fe12c69f
--- /dev/null
+++ b/Lombiq.HelpfulLibraries.Tests/UnitTests/Utilities/VersionTreeTests.cs
@@ -0,0 +1,61 @@
+using Lombiq.HelpfulLibraries.Common.Utilities;
+using Shouldly;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using Xunit;
+
+namespace Lombiq.HelpfulLibraries.Tests.UnitTests.Utilities;
+
+public class VersionTreeTests
+{
+ private static readonly JsonSerializerOptions _indentedJsonOption = new() { WriteIndented = true };
+
+ private static readonly List _versions =
+ "1.0 1.2 1.2.1 1.2.2 1.2.3 1.3 1.4 1.4.1 1.4.1.1 1.4.1.2 1.4.1.3 1.4.2.1 1.4.2.2 1.4.2.4"
+ .Split()
+ .Select(Version.Parse)
+ .ToList();
+
+ [Fact]
+ public void VersionTreeShouldHaveExpectedStructure()
+ {
+ var tree = VersionTree.Create(_versions);
+ tree.Versions.ShouldBe(_versions);
+
+ ShouldMatchJson(tree, File.ReadAllText("VersionTreeTests.FullStructure.json"));
+ }
+
+ [Fact]
+ public void IndexingByNumberAndVersionShouldWork()
+ {
+ var tree = VersionTree.Create(_versions);
+ var expectedRevision = new Version(1, 4, 1, 1);
+
+ tree[1]![4]![1]![1]!.Versions.ShouldBe([expectedRevision]);
+ tree[expectedRevision]!.Versions.ShouldBe([expectedRevision]);
+ }
+
+ [Fact]
+ public void VersionSubtreeShouldHaveExpectedStructure()
+ {
+ var tree = VersionTree.Create(_versions);
+ var expectedSubtree = File.ReadAllText("VersionTreeTests.Subtree.json");
+
+ // Verify full subtree structure.
+ ShouldMatchJson(tree[1]![4], expectedSubtree);
+
+ // Verify that indexing -1 results in the same.
+ ShouldMatchJson(tree[1]![4]![-1]![-1], expectedSubtree);
+ ShouldMatchJson(tree[new Version(1, 4)], expectedSubtree);
+ }
+
+ private static void ShouldMatchJson(VersionTree tree, string expectedJson)
+ {
+ var actualJson = JsonSerializer.Serialize(tree, _indentedJsonOption);
+ (string.Join(string.Empty, actualJson.Split()) == string.Join(string.Empty, expectedJson.Split()))
+ .ShouldBeTrue($"Actual JSON:\n{actualJson}\nExpected JSON: {expectedJson}\n(whitespace does not matter)");
+ }
+}
diff --git a/Lombiq.HelpfulLibraries.Tests/VersionTreeTests.FullStructure.json b/Lombiq.HelpfulLibraries.Tests/VersionTreeTests.FullStructure.json
new file mode 100644
index 00000000..11cef9da
--- /dev/null
+++ b/Lombiq.HelpfulLibraries.Tests/VersionTreeTests.FullStructure.json
@@ -0,0 +1,149 @@
+{
+ "Versions": [
+ "1.0",
+ "1.2",
+ "1.2.1",
+ "1.2.2",
+ "1.2.3",
+ "1.3",
+ "1.4",
+ "1.4.1",
+ "1.4.1.1",
+ "1.4.1.2",
+ "1.4.1.3",
+ "1.4.2.1",
+ "1.4.2.2",
+ "1.4.2.4"
+ ],
+ "SubVersions": {
+ "1": {
+ "Versions": [
+ "1.0",
+ "1.2",
+ "1.2.1",
+ "1.2.2",
+ "1.2.3",
+ "1.3",
+ "1.4",
+ "1.4.1",
+ "1.4.1.1",
+ "1.4.1.2",
+ "1.4.1.3",
+ "1.4.2.1",
+ "1.4.2.2",
+ "1.4.2.4"
+ ],
+ "SubVersions": {
+ "0": {
+ "Versions": [
+ "1.0"
+ ],
+ "SubVersions": {}
+ },
+ "2": {
+ "Versions": [
+ "1.2",
+ "1.2.1",
+ "1.2.2",
+ "1.2.3"
+ ],
+ "SubVersions": {
+ "1": {
+ "Versions": [
+ "1.2.1"
+ ],
+ "SubVersions": {}
+ },
+ "2": {
+ "Versions": [
+ "1.2.2"
+ ],
+ "SubVersions": {}
+ },
+ "3": {
+ "Versions": [
+ "1.2.3"
+ ],
+ "SubVersions": {}
+ }
+ }
+ },
+ "3": {
+ "Versions": [
+ "1.3"
+ ],
+ "SubVersions": {}
+ },
+ "4": {
+ "Versions": [
+ "1.4",
+ "1.4.1",
+ "1.4.1.1",
+ "1.4.1.2",
+ "1.4.1.3",
+ "1.4.2.1",
+ "1.4.2.2",
+ "1.4.2.4"
+ ],
+ "SubVersions": {
+ "1": {
+ "Versions": [
+ "1.4.1",
+ "1.4.1.1",
+ "1.4.1.2",
+ "1.4.1.3"
+ ],
+ "SubVersions": {
+ "1": {
+ "Versions": [
+ "1.4.1.1"
+ ],
+ "SubVersions": {}
+ },
+ "2": {
+ "Versions": [
+ "1.4.1.2"
+ ],
+ "SubVersions": {}
+ },
+ "3": {
+ "Versions": [
+ "1.4.1.3"
+ ],
+ "SubVersions": {}
+ }
+ }
+ },
+ "2": {
+ "Versions": [
+ "1.4.2.1",
+ "1.4.2.2",
+ "1.4.2.4"
+ ],
+ "SubVersions": {
+ "1": {
+ "Versions": [
+ "1.4.2.1"
+ ],
+ "SubVersions": {}
+ },
+ "2": {
+ "Versions": [
+ "1.4.2.2"
+ ],
+ "SubVersions": {}
+ },
+ "4": {
+ "Versions": [
+ "1.4.2.4"
+ ],
+ "SubVersions": {}
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Lombiq.HelpfulLibraries.Tests/VersionTreeTests.Subtree.json b/Lombiq.HelpfulLibraries.Tests/VersionTreeTests.Subtree.json
new file mode 100644
index 00000000..ecc3d553
--- /dev/null
+++ b/Lombiq.HelpfulLibraries.Tests/VersionTreeTests.Subtree.json
@@ -0,0 +1,69 @@
+{
+ "Versions": [
+ "1.4",
+ "1.4.1",
+ "1.4.1.1",
+ "1.4.1.2",
+ "1.4.1.3",
+ "1.4.2.1",
+ "1.4.2.2",
+ "1.4.2.4"
+ ],
+ "SubVersions": {
+ "1": {
+ "Versions": [
+ "1.4.1",
+ "1.4.1.1",
+ "1.4.1.2",
+ "1.4.1.3"
+ ],
+ "SubVersions": {
+ "1": {
+ "Versions": [
+ "1.4.1.1"
+ ],
+ "SubVersions": {}
+ },
+ "2": {
+ "Versions": [
+ "1.4.1.2"
+ ],
+ "SubVersions": {}
+ },
+ "3": {
+ "Versions": [
+ "1.4.1.3"
+ ],
+ "SubVersions": {}
+ }
+ }
+ },
+ "2": {
+ "Versions": [
+ "1.4.2.1",
+ "1.4.2.2",
+ "1.4.2.4"
+ ],
+ "SubVersions": {
+ "1": {
+ "Versions": [
+ "1.4.2.1"
+ ],
+ "SubVersions": {}
+ },
+ "2": {
+ "Versions": [
+ "1.4.2.2"
+ ],
+ "SubVersions": {}
+ },
+ "4": {
+ "Versions": [
+ "1.4.2.4"
+ ],
+ "SubVersions": {}
+ }
+ }
+ }
+ }
+}