diff --git a/src/cli/error-logging.ts b/src/cli/error-logging.ts index d920edbe..a8ecc9a5 100644 --- a/src/cli/error-logging.ts +++ b/src/cli/error-logging.ts @@ -21,6 +21,7 @@ import { } from "../domain/common-errors"; import { stringifyEditorVersion } from "../domain/editor-version"; import { + NoStableError, NoVersionsError, VersionNotFoundError, type ResolvePackumentVersionError, @@ -96,6 +97,8 @@ function makeErrorMessageFor(error: unknown): string { return "Could not determine path of home directory."; if (error instanceof NoSystemUserProfilePath) return "Could not determine path of system user directory."; + if (error instanceof NoStableError) + return "Seems like the package you requested has no stable versions."; return "A fatal error occurred."; } diff --git a/src/domain/package-reference.ts b/src/domain/package-reference.ts index 60764d84..6f09b51d 100644 --- a/src/domain/package-reference.ts +++ b/src/domain/package-reference.ts @@ -5,11 +5,21 @@ import { SemanticVersion } from "./semantic-version"; import { trySplitAtFirstOccurrenceOf } from "./string-utils"; import { assertZod, isZod } from "./zod-utils"; +/** + * The "latest" tag string. Specifies that the latest version is requested. + */ +export type LatestTag = "latest"; + +/** + * The "stable" tag string. Specifies that the latest stable version is + * requested. + */ +export type StableTag = "stable"; + /** * A string with the format of one of the supported version tags. - * NOTE: Currently we only support "latest". */ -export type PackageTag = "latest"; +export type PackageTag = LatestTag | StableTag; /** * Reference to a version, either directly by a semantic version or via an @@ -23,7 +33,7 @@ export type VersionReference = SemanticVersion | PackageUrl | PackageTag; export type ReferenceWithVersion = `${DomainName}@${VersionReference}`; /** - * A version-reference that is resolvable. + * A {@link VersionReference} that is resolvable. * Mostly this excludes {@link PackageUrl}s. */ export type ResolvableVersion = Exclude; diff --git a/src/domain/packument.ts b/src/domain/packument.ts index f741bb72..465eb91a 100644 --- a/src/domain/packument.ts +++ b/src/domain/packument.ts @@ -4,9 +4,13 @@ import { Err, Ok, Result } from "ts-results-es"; import { PackumentNotFoundError } from "./common-errors"; import { DomainName } from "./domain-name"; import { UnityPackageManifest } from "./package-manifest"; -import { ResolvableVersion } from "./package-reference"; +import { + ResolvableVersion, + type LatestTag, + type StableTag, +} from "./package-reference"; import { recordKeys } from "./record-utils"; -import { SemanticVersion } from "./semantic-version"; +import { compareVersions, isStable, SemanticVersion } from "./semantic-version"; /** * Contains information about a specific version of a package. This is based on @@ -140,6 +144,21 @@ export class VersionNotFoundError extends CustomError { } } +/** + * Error for when the latest stable version of a packument was requested, but + * the packument had no stable versions. + */ +export class NoStableError extends CustomError { + constructor( + /** + * The name of the packument. + */ + public readonly packageName: DomainName + ) { + super(); + } +} + /** * A failed attempt at resolving a packument-version. */ @@ -157,8 +176,20 @@ export type ResolvePackumentVersionError = */ export function tryResolvePackumentVersion( packument: UnityPackument, - requestedVersion: "latest" + requestedVersion: LatestTag ): Result; +/** + * Resolved the latest stable version from a packument. + * @param packument The packument. + * @param requestedVersion The version to resolve. In this case indicates that + * the latest stable version is requested. + * @returns Result containing the resolved version or an error. + * @throws {NoVersionsError} If the packument had no versions at all. + */ +export function tryResolvePackumentVersion( + packument: UnityPackument, + requestedVersion: StableTag +): Result; /** * Attempts to resolve a specific version from a packument. * @param packument The packument. @@ -187,24 +218,27 @@ export function tryResolvePackumentVersion( packument: UnityPackument, requestedVersion: ResolvableVersion ) { - const availableVersions = recordKeys(packument.versions); - if (availableVersions.length === 0) throw new NoVersionsError(packument.name); + const allVersions = recordKeys(packument.versions); + if (allVersions.length === 0) throw new NoVersionsError(packument.name); + const sortedVersions = allVersions.slice().sort(compareVersions); // Find the latest version if (requestedVersion === "latest") { let latestVersion = tryGetLatestVersion(packument); - if (latestVersion === null) latestVersion = availableVersions.at(-1)!; + if (latestVersion === null) latestVersion = sortedVersions.at(-1)!; return Ok(tryGetPackumentVersion(packument, latestVersion)!); } + if (requestedVersion === "stable") { + const version = sortedVersions.findLast(isStable); + if (version === undefined) return Err(new NoStableError(packument.name)); + return Ok(tryGetPackumentVersion(packument, version)!); + } + // Find a specific version - if (!availableVersions.includes(requestedVersion)) + if (!sortedVersions.includes(requestedVersion)) return Err( - new VersionNotFoundError( - packument.name, - requestedVersion, - availableVersions - ) + new VersionNotFoundError(packument.name, requestedVersion, sortedVersions) ); return Ok(tryGetPackumentVersion(packument, requestedVersion)!); diff --git a/src/domain/semantic-version.ts b/src/domain/semantic-version.ts index 18e0c52c..3d262775 100644 --- a/src/domain/semantic-version.ts +++ b/src/domain/semantic-version.ts @@ -14,3 +14,28 @@ export const SemanticVersion = z * @see https://semver.org/. */ export type SemanticVersion = z.TypeOf; + +/** + * Compares to semantic versions to see which is larger. Can be used for + * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/toSorted Array.prototype.toSorted}. + * @param a The first version. + * @param b The second version. + * @returns A number indicating the sorting order of the numbers. + * - a > b -> 1. + * - a = b -> 0. + * - a < b -> -1. + */ +export function compareVersions( + a: SemanticVersion, + b: SemanticVersion +): -1 | 0 | 1 { + return semver.compare(a, b, false); +} + +/** + * Checks wheter a semantic version is stable. + * @param version The version to check. + */ +export function isStable(version: SemanticVersion): boolean { + return semver.prerelease(version) === null; +} diff --git a/test/common/data-packument.ts b/test/common/data-packument.ts index 78e85c86..ab154f11 100644 --- a/test/common/data-packument.ts +++ b/test/common/data-packument.ts @@ -74,13 +74,17 @@ class UnityPackumentBuilder { /** * Adds a version to this package. + * The order in which you add versions to the packument is important because + * every time you call this function the version specified in the packument's + * "dist-tags" is overriden to the given version. * @param version The name of the version. * @param build A builder function. + * @returns The builder for chaining. */ addVersion( version: string, build?: (builder: UnityPackumentVersionBuilder) => unknown - ): UnityPackumentBuilder { + ): this { assert(isZod(version, SemanticVersion), `${version} is semantic version`); const builder = new UnityPackumentVersionBuilder( this.packument.name, diff --git a/test/unit/domain/packument.test.ts b/test/unit/domain/packument.test.ts index d0c14fa6..285f11ca 100644 --- a/test/unit/domain/packument.test.ts +++ b/test/unit/domain/packument.test.ts @@ -1,6 +1,7 @@ import { Ok } from "ts-results-es"; import { DomainName } from "../../../src/domain/domain-name"; import { + NoStableError, NoVersionsError, packumentHasVersion, tryGetLatestVersion, @@ -87,10 +88,30 @@ describe("packument", () => { ).toThrow(NoVersionsError); }); - it("should find latest version when requested", () => { - const result = tryResolvePackumentVersion(somePackument, "latest"); + it("should find latest version for packument where latest is stable", () => { + const packument = buildPackument(somePackage, (packument) => + packument.addVersion("0.8.0").addVersion("1.0.0") + ); + + const packumentVersion = tryResolvePackumentVersion( + packument, + "latest" + ).unwrap(); + + expect(packumentVersion.version).toEqual("1.0.0"); + }); - expect(result).toEqual(Ok(somePackument.versions[someHighVersion]!)); + it("should find latest version for packument where latest is pre", () => { + const packument = buildPackument(somePackage, (packument) => + packument.addVersion("1.0.0").addVersion("1.1.0-pre") + ); + + const packumentVersion = tryResolvePackumentVersion( + packument, + "latest" + ).unwrap(); + + expect(packumentVersion.version).toEqual("1.1.0-pre"); }); it("should find specific version", () => { @@ -110,6 +131,29 @@ describe("packument", () => { const error = result.unwrapErr(); expect(error).toBeInstanceOf(VersionNotFoundError); }); + + it("should find latest stable version if there is one", () => { + const packument = buildPackument(somePackage, (packument) => + packument.addVersion("2.0.0-pre").addVersion("1.0.0") + ); + + const packumentVersion = tryResolvePackumentVersion( + packument, + "stable" + ).unwrap(); + + expect(packumentVersion.version).toEqual("1.0.0"); + }); + + it("should fail to find latest stable version if there is none", () => { + const packument = buildPackument(somePackage, (packument) => + packument.addVersion("1.0.0-pre.0").addVersion("1.0.0-pre.1") + ); + + const error = tryResolvePackumentVersion(packument, "stable").unwrapErr(); + + expect(error).toBeInstanceOf(NoStableError); + }); }); describe("has version", () => {