From ea82a5ff4408e119450bb4664e2117bc1ed42ebe Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Sat, 25 Apr 2026 22:46:11 -0500 Subject: [PATCH] Initializing the repository with some basic types. --- .forgejo/workflows/pull_request.yaml | 67 ++++++ .forgejo/workflows/release.yaml | 84 ++++++++ .gitignore | 6 + .pre-commit-config.yaml | 17 ++ .scalafmt.conf | 71 +++++++ LICENSE | 202 +++++++++++++++++++ README.md | 54 +++++ build.sbt | 31 +++ project/build.properties | 1 + project/plugins.sbt | 34 ++++ src/main/scala/gs/std/v0/Base64Decoder.scala | 36 ++++ src/main/scala/gs/std/v0/Base64Encoder.scala | 43 ++++ src/main/scala/gs/std/v0/Blob.scala | 44 ++++ src/main/scala/gs/std/v0/ByteCount.scala | 132 ++++++++++++ src/main/scala/gs/std/v0/CreatedAt.scala | 38 ++++ src/main/scala/gs/std/v0/Decoder.scala | 13 ++ src/main/scala/gs/std/v0/Encode.scala | 58 ++++++ src/main/scala/gs/std/v0/EncodedString.scala | 162 +++++++++++++++ src/main/scala/gs/std/v0/Encoder.scala | 44 ++++ src/main/scala/gs/std/v0/HexDecoder.scala | 23 +++ src/main/scala/gs/std/v0/HexEncoder.scala | 13 ++ src/main/scala/gs/std/v0/MD5.scala | 105 ++++++++++ src/main/scala/gs/std/v0/Nat.scala | 81 ++++++++ src/main/scala/gs/std/v0/SHA256.scala | 105 ++++++++++ src/main/scala/gs/std/v0/Size.scala | 97 +++++++++ src/main/scala/gs/std/v0/UpdatedAt.scala | 39 ++++ 26 files changed, 1600 insertions(+) create mode 100644 .forgejo/workflows/pull_request.yaml create mode 100644 .forgejo/workflows/release.yaml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .scalafmt.conf create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build.sbt create mode 100644 project/build.properties create mode 100644 project/plugins.sbt create mode 100644 src/main/scala/gs/std/v0/Base64Decoder.scala create mode 100644 src/main/scala/gs/std/v0/Base64Encoder.scala create mode 100644 src/main/scala/gs/std/v0/Blob.scala create mode 100644 src/main/scala/gs/std/v0/ByteCount.scala create mode 100644 src/main/scala/gs/std/v0/CreatedAt.scala create mode 100644 src/main/scala/gs/std/v0/Decoder.scala create mode 100644 src/main/scala/gs/std/v0/Encode.scala create mode 100644 src/main/scala/gs/std/v0/EncodedString.scala create mode 100644 src/main/scala/gs/std/v0/Encoder.scala create mode 100644 src/main/scala/gs/std/v0/HexDecoder.scala create mode 100644 src/main/scala/gs/std/v0/HexEncoder.scala create mode 100644 src/main/scala/gs/std/v0/MD5.scala create mode 100644 src/main/scala/gs/std/v0/Nat.scala create mode 100644 src/main/scala/gs/std/v0/SHA256.scala create mode 100644 src/main/scala/gs/std/v0/Size.scala create mode 100644 src/main/scala/gs/std/v0/UpdatedAt.scala diff --git a/.forgejo/workflows/pull_request.yaml b/.forgejo/workflows/pull_request.yaml new file mode 100644 index 0000000..d331681 --- /dev/null +++ b/.forgejo/workflows/pull_request.yaml @@ -0,0 +1,67 @@ +on: + pull_request: + types: [opened, synchronize, reopened] + +defaults: + run: + shell: bash + +jobs: + library_snapshot: + runs-on: docker + container: + image: registry.garrity.co:8443/gs/ci-scala:latest + name: 'Build and Test Library Snapshot' + env: + GS_MAVEN_USER: ${{ vars.GS_MAVEN_USER }} + GS_MAVEN_TOKEN: ${{ secrets.GS_MAVEN_TOKEN }} + steps: + - uses: actions/checkout@v4 + name: 'Checkout Repository' + with: + fetch-depth: 0 + - name: 'Pre-Commit' + run: | + pre-commit install + pre-commit run --all-files + - name: 'Prepare Versioned Build' + run: | + latest_git_tag="$(git describe --tags --abbrev=0 || echo 'No Tags')" + latest_commit_message="$(git show -s --format=%s HEAD)" + if [[ "$latest_commit_message" == *"(major)"* ]]; then + export GS_RELEASE_TYPE="major" + elif [[ "$latest_commit_message" == *"(minor)"* ]]; then + export GS_RELEASE_TYPE="minor" + elif [[ "$latest_commit_message" == *"(patch)"* ]]; then + export GS_RELEASE_TYPE="patch" + elif [[ "$latest_commit_message" == *"(docs)"* ]]; then + export GS_RELEASE_TYPE="norelease" + elif [[ "$latest_commit_message" == *"(norelease)"* ]]; then + export GS_RELEASE_TYPE="norelease" + else + export GS_RELEASE_TYPE="norelease" + fi + echo "GS_RELEASE_TYPE=$GS_RELEASE_TYPE" >> $GITHUB_ENV + echo "Previous Git Tag: $latest_git_tag" + echo "Latest Commit: $latest_commit_message ($GS_RELEASE_TYPE) (SNAPSHOT)" + if [ "$GS_RELEASE_TYPE" = "norelease" ]; then + sbtn -Dsnapshot=true -Drelease="patch" semVerInfo + else + sbtn -Dsnapshot=true -Drelease="$GS_RELEASE_TYPE" semVerInfo + fi + - name: 'Unit Tests and Code Coverage' + run: | + sbtn clean + sbtn coverage + sbtn test + sbtn coverageReport + - name: 'Publish Snapshot' + run: | + echo "Testing env var propagation = ${{ env.GS_RELEASE_TYPE }}" + if [ "${{ env.GS_RELEASE_TYPE }}" = "norelease" ]; then + echo "Skipping publish due to GS_RELEASE_TYPE=norelease" + else + sbtn coverageOff + sbtn clean + sbtn publish + fi diff --git a/.forgejo/workflows/release.yaml b/.forgejo/workflows/release.yaml new file mode 100644 index 0000000..72fac7d --- /dev/null +++ b/.forgejo/workflows/release.yaml @@ -0,0 +1,84 @@ +on: + push: + branches: + - main + +defaults: + run: + shell: bash + +jobs: + library_release: + runs-on: docker + container: + image: registry.garrity.co:8443/gs/ci-scala:latest + name: 'Build and Release Library' + env: + GS_MAVEN_USER: ${{ vars.GS_MAVEN_USER }} + GS_MAVEN_TOKEN: ${{ secrets.GS_MAVEN_TOKEN }} + steps: + - uses: actions/checkout@v4 + name: 'Checkout Repository' + with: + fetch-depth: 0 + - name: 'Pre-Commit' + run: | + pre-commit install + pre-commit run --all-files + - name: 'Prepare Versioned Build' + run: | + latest_git_tag="$(git describe --tags --abbrev=0 || echo 'No Tags')" + latest_commit_message="$(git show -s --format=%s HEAD)" + if [[ "$latest_commit_message" == *"(major)"* ]]; then + export GS_RELEASE_TYPE="major" + elif [[ "$latest_commit_message" == *"(minor)"* ]]; then + export GS_RELEASE_TYPE="minor" + elif [[ "$latest_commit_message" == *"(patch)"* ]]; then + export GS_RELEASE_TYPE="patch" + elif [[ "$latest_commit_message" == *"(docs)"* ]]; then + export GS_RELEASE_TYPE="norelease" + elif [[ "$latest_commit_message" == *"(norelease)"* ]]; then + export GS_RELEASE_TYPE="norelease" + else + export GS_RELEASE_TYPE="norelease" + fi + + echo "GS_RELEASE_TYPE=$GS_RELEASE_TYPE" >> $GITHUB_ENV + echo "Previous Git Tag: $latest_git_tag" + echo "Latest Commit: $latest_commit_message" + echo "Selected Release Type: '$GS_RELEASE_TYPE'" + + if [ "$GS_RELEASE_TYPE" = "norelease" ]; then + echo "Skipping all versioning for 'norelease' commit." + else + sbtn -Drelease="$GS_RELEASE_TYPE" semVerInfo + fi + - name: 'Unit Tests and Code Coverage' + run: | + if [ "${{ env.GS_RELEASE_TYPE }}" = "norelease" ]; then + echo "Skipping build/test for 'norelease' commit." + else + sbtn clean + sbtn coverage + sbtn test + sbtn coverageReport + fi + - name: 'Publish Release' + run: | + if [ "${{ env.GS_RELEASE_TYPE }}" = "norelease" ]; then + echo "Skipping publish for 'norelease' commit." + else + sbtn coverageOff + sbtn clean + sbtn semVerWriteVersionToFile + sbtn publish + fi + - name: 'Create Git Tag' + run: | + if [ "${{ env.GS_RELEASE_TYPE }}" = "norelease" ]; then + echo "Skipping Git tag for 'norelease' commit." + else + selected_version="$(cat .version)" + git tag "$selected_version" + git push origin "$selected_version" + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d2af1f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +target/ +project/target/ +project/project/ +modules/core/target/ +.version +.scala-version diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..7a7e7c7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +--- +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - id: fix-byte-order-marker + - id: mixed-line-ending + args: ['--fix=lf'] + description: Enforces using only 'LF' line endings. + - id: trailing-whitespace + - id: check-yaml + - repo: https://git.garrity.co/garrity-software/gs-pre-commit-scala + rev: v1.0.2 + hooks: + - id: scalafmt diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..11f6020 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,71 @@ +// See: https://github.com/scalameta/scalafmt/tags for the latest tags. +version = 3.10.7 +runner.dialect = scala3 +maxColumn = 80 + +rewrite { + rules = [RedundantBraces, RedundantParens, Imports, SortModifiers] + imports.expand = true + imports.sort = scalastyle + redundantBraces.ifElseExpressions = true + redundantBraces.stringInterpolation = true +} + +indent { + main = 2 + callSite = 2 + defnSite = 2 + extendSite = 4 + withSiteRelativeToExtends = 2 + commaSiteRelativeToExtends = 2 +} + +align { + preset = more + openParenCallSite = false + openParenDefnSite = false +} + +newlines { + implicitParamListModifierForce = [before,after] + topLevelStatementBlankLines = [ + { + blanks = 1 + } + ] + afterCurlyLambdaParams = squash +} + +danglingParentheses { + defnSite = true + callSite = true + ctrlSite = true + exclude = [] +} + +verticalMultiline { + atDefnSite = true + arityThreshold = 2 + newlineAfterOpenParen = true +} + +comments { + wrap = standalone +} + +docstrings { + style = "SpaceAsterisk" + oneline = unfold + wrap = yes + forceBlankLineBefore = true +} + +project { + excludePaths = [ + "glob:**target/**", + "glob:**.metals/**", + "glob:**.bloop/**", + "glob:**.bsp/**", + "glob:**metals.sbt" + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bebab0c --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# gs-std + +[GS Open Source](https://garrity.co/oss.html) | +[License (Apache 2.0)](./LICENSE) + +Garrity Software standard types and operations. Provides a zero-dependency +collection of basic tools. + +- [Usage](#usage) + - [Dependency](#dependency) +- [Donate](#donate) + +## Usage + +### Dependency + +This artifact is available in the Garrity Software Maven repository. + +```scala +externalResolvers += + "Garrity Software Releases" at "https://maven.garrity.co/releases" + +val GsStd: ModuleID = + "gs" %% "gs-std-v0" % "$VERSION" +``` + +## Types + +- `Nat` +- `Size` +- `ByteCount` +- `Blob` +- `CreatedAt` +- `UpdatedAt` +- `SHA256` +- `MD5` +- `EncodedString` +- `B64` +- `B64Url` +- `Hex` + +## Tools + +- `Encoder` +- `Base64Encoder` +- `HexEncoder` +- `Decoder` +- `Base64Decoder` +- `HexDecoder` + +## Donate + +Enjoy this project or want to help me achieve my [goals](https://garrity.co)? +Consider [Donating to Pat on Ko-fi](https://ko-fi.com/gspfm). diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..f2d5856 --- /dev/null +++ b/build.sbt @@ -0,0 +1,31 @@ +val scala3: String = "3.8.3" + +ThisBuild / scalaVersion := scala3 +ThisBuild / versionScheme := Some("semver-spec") +ThisBuild / gsProjectName := "gs-std" + +ThisBuild / licenses := Seq( + "Apache 2.0" -> url( + "https://git.garrity.co/garrity-software/gs-std/src/branch/main/LICENSE" + ) +) + +val sharedSettings = Seq( + scalaVersion := scala3, + version := semVerSelected.value, + coverageFailOnMinimum := true, + coverageMinimumStmtTotal := 100, + coverageMinimumBranchTotal := 100 +) + +lazy val testSettings = Seq( + libraryDependencies ++= Seq( + "org.scalameta" %% "munit" % "1.3.0" % Test + ) +) + +lazy val `gs-std` = project + .in(file(".")) + .settings(sharedSettings) + .settings(testSettings) + .settings(name := s"${gsProjectName.value}-v${semVerMajor.value}") diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..df061f4 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.12.9 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..9691bc6 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,34 @@ +def selectCredentials(): Credentials = + if ((Path.userHome / ".sbt" / ".credentials").exists()) + Credentials(Path.userHome / ".sbt" / ".credentials") + else + Credentials.apply( + realm = "Reposilite", + host = "maven.garrity.co", + userName = sys.env + .get("GS_MAVEN_USER") + .getOrElse( + throw new RuntimeException( + "You must either provide ~/.sbt/.credentials or specify the GS_MAVEN_USER environment variable." + ) + ), + passwd = sys.env + .get("GS_MAVEN_TOKEN") + .getOrElse( + throw new RuntimeException( + "You must either provide ~/.sbt/.credentials or specify the GS_MAVEN_TOKEN environment variable." + ) + ) + ) + +credentials += selectCredentials() + +externalResolvers := Seq( + "Garrity Software Mirror" at "https://maven.garrity.co/releases", + "Garrity Software Snapshots" at "https://maven.garrity.co/snapshots", + "Garrity Software Releases" at "https://maven.garrity.co/gs" +) + +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.4.4") +addSbtPlugin("gs" % "sbt-garrity-software" % "0.7.0") +addSbtPlugin("gs" % "sbt-gs-semver" % "0.3.0") diff --git a/src/main/scala/gs/std/v0/Base64Decoder.scala b/src/main/scala/gs/std/v0/Base64Decoder.scala new file mode 100644 index 0000000..f789979 --- /dev/null +++ b/src/main/scala/gs/std/v0/Base64Decoder.scala @@ -0,0 +1,36 @@ +package gs.std.v0 + +import java.{util => ju} + +/** Implementation of [[Decoder]] for Base64 strings. + * + * Supports base64-url decoding as well. + */ +object Base64Decoder extends Decoder[B64]: + private lazy val d: ju.Base64.Decoder = ju.Base64.getDecoder() + private lazy val du: ju.Base64.Decoder = ju.Base64.getUrlDecoder() + + /** @inheritDocs + */ + override def decode(input: B64): Array[Byte] = + d.decode(input.data) + + /** Decode some arbitrary string data. + * + * @param input + * The data to decode. + * @return + * The decoded bytes. + */ + def decodeUnsafe(input: String): Array[Byte] = + d.decode(input) + + /** Decode the base64-url encoded input. + * + * @param input + * The base64-url encoded data. + * @return + * The decoded bytes. + */ + def decodeUrl(input: B64Url): Array[Byte] = + du.decode(input.data) diff --git a/src/main/scala/gs/std/v0/Base64Encoder.scala b/src/main/scala/gs/std/v0/Base64Encoder.scala new file mode 100644 index 0000000..935cdfd --- /dev/null +++ b/src/main/scala/gs/std/v0/Base64Encoder.scala @@ -0,0 +1,43 @@ +package gs.std.v0 + +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.util.Base64 + +/** Implementation of [[Encoder]] for Base64. + * + * Supports base64-url encoding as well. + */ +object Base64Encoder extends Encoder[B64]: + private lazy val e: Base64.Encoder = Base64.getEncoder() + private lazy val eu: Base64.Encoder = Base64.getUrlEncoder() + + /** @inheritDocs + */ + override def encode(input: Array[Byte]): B64 = + B64(e.encodeToString(input)) + + /** Encode the given bytes using base64-url. + * + * @param input + * The input data. + * @return + * The base64-url-encoded string. + */ + def encodeUrl(input: Array[Byte]): B64Url = + B64Url(eu.encodeToString(input)) + + /** Encode the given bytes using base64-url. + * + * @param input + * The input data. + * @param charset + * The character set of the input data. + * @return + * The base64-url-encoded string. + */ + def encodeUrl( + input: String, + charset: Charset = StandardCharsets.UTF_8 + ): B64Url = + encodeUrl(input.getBytes(charset)) diff --git a/src/main/scala/gs/std/v0/Blob.scala b/src/main/scala/gs/std/v0/Blob.scala new file mode 100644 index 0000000..d18a759 --- /dev/null +++ b/src/main/scala/gs/std/v0/Blob.scala @@ -0,0 +1,44 @@ +package gs.std.v0 + +import java.util.Base64 + +/** + * Represents a blob -- some array of bytes. + * + * @param data The underlying data. + */ +final class Blob(private val data: Array[Byte]) extends IndexedSeq[Byte]: + + /** @inheritDocs */ + override def apply(i: Int): Byte = byteAt(i) + + /** @inheritDocs */ + override def length: Int = data.length + + /** + * @return The number of bytes in this blob, expressed as a count. + */ + def numberOfBytes: ByteCount = ByteCount(data.length) + + /** @inheritDocs */ + override def equals(obj: Any): Boolean = + obj match + case other: Blob => data.sameElements(other.data) + case other: Array[Byte] => data.sameElements(other) + case _ => false + + /** @inheritDocs */ + override def hashCode(): Int = data.hashCode() + + /** + * Retrieve the byte at the given index. + * + * @param index The index. + * @return The byte stored at the given index. + */ + def byteAt(index: Int): Byte = data.apply(index) + + /** + * @return This byte array, encoded as a base64 string. + */ + def base64(): String = Base64.getEncoder().encodeToString(data) diff --git a/src/main/scala/gs/std/v0/ByteCount.scala b/src/main/scala/gs/std/v0/ByteCount.scala new file mode 100644 index 0000000..8a95565 --- /dev/null +++ b/src/main/scala/gs/std/v0/ByteCount.scala @@ -0,0 +1,132 @@ +package gs.std.v0 + +/** + * Opaque type for some number of bytes (>= 0). + */ +opaque type ByteCount = Long + +/** + * Opaque type for some number of bytes (>= 0). + */ +object ByteCount: + + /** + * 0 bytes. + */ + final val Zero: ByteCount = 0 + + /** + * 1,000 bytes. + */ + final val OneKilobyte: ByteCount = 1000 + + /** + * 1,000,000 bytes. + */ + final val OneMegabyte: ByteCount = 1000000 + + /** + * 1,000,000,000 bytes. + */ + final val OneGigabyte: ByteCount = 1000000000 + + /** + * Express the given number as a byte count. All values are normalized to the + * absolute value -- negative values are coerced to positive. + * + * @param value The input integer. + * @return The [[ByteCount]] instance. + */ + def apply(value: Long): ByteCount = Math.abs(value) + + /** + * 1 kilobyte = 1,000 bytes + * + * @param kb The number of kilobytes. + * @return The number of bytes. + */ + def fromKilobytes(kb: Long): ByteCount = + Math.abs(kb) * 1000L + + /** + * 1 megabyte = 1,000,000 bytes + * + * @param mb The number of megabytes. + * @return The number of bytes. + */ + def fromMegabytes(mb: Long): ByteCount = + Math.abs(mb) * 1000000L + + /** + * 1 gigabyte = 1,000,000,000 bytes + * + * @param mb The number of gigabytes. + * @return The number of bytes. + */ + def fromGigabytes(gb: Long): ByteCount = + Math.abs(gb) * 1000000000 + + given CanEqual[ByteCount, ByteCount] = CanEqual.derived + + given Ordering[ByteCount] with + /** @inheritDocs */ + def compare(x: ByteCount, y: ByteCount): Int = + if x > y then 1 else if x == y then 0 else -1 + + extension (byteCount: ByteCount) + /** + * @return The underlying `Long`. + */ + def unwrap(): Long = byteCount + + /** + * @return The underlying `Long`. + */ + def toLong(): Long = byteCount + + /** + * 1 kilobyte = 1,000 bytes + * + * @return The number of kilobytes represented by this count. + */ + def toKilobytes(): Double = byteCount / 1000.0 + + /** + * 1 megabyte = 1,000,000 bytes. + * + * @return The number of megabytes represented by this count. + */ + def toMegabytes(): Double = byteCount / 1000000.0 + + /** + * 1 gigabyte = 1,000,000,000 bytes. + * + * @return The number of gigabytes represented by this count. + */ + def toGigabytes(): Double = byteCount / 1000000000.0 + + /** + * Add some count to this one. + * + * @param that The number to add. + * @return The sum of the numbers. + */ + def +(that: ByteCount): ByteCount = byteCount + that + + /** + * Multiply this count by some other count. + * + * @param that The number to multiply by. + * @return The product of the numbers. + */ + def *(that: ByteCount): ByteCount = byteCount * that + + /** + * Check if this value is the same as some number. + * + * @param value The number to compare against. + * @return True if the values are equal, false otherwise. + */ + def equal(value: Int): Boolean = byteCount == value.toLong + +end ByteCount diff --git a/src/main/scala/gs/std/v0/CreatedAt.scala b/src/main/scala/gs/std/v0/CreatedAt.scala new file mode 100644 index 0000000..4e47dcc --- /dev/null +++ b/src/main/scala/gs/std/v0/CreatedAt.scala @@ -0,0 +1,38 @@ +package gs.std.v0 + +import java.time.Instant + +/** + * Opaque type that represents the instant something was created. + */ +opaque type CreatedAt = Instant + +/** + * Opaque type that represents the instant something was created. + */ +object CreatedAt: + + /** + * Instantiate a new [[CreatedAt]] from the given `java.time.Instant`. + * + * @param value The value to semantically represent. + * @return The new [[CreatedAt]]. + */ + def apply(value: Instant): CreatedAt = value + + given CanEqual[CreatedAt, CreatedAt] = CanEqual.derived + + given Ordering[CreatedAt] = Ordering[Instant] + + extension (createdAt: Instant) + /** + * @return The underlying `java.time.Instant`. + */ + def unwrap(): Instant = createdAt + + /** + * @return The underlying `java.time.Instant`. + */ + def toInstant(): Instant = createdAt + +end CreatedAt diff --git a/src/main/scala/gs/std/v0/Decoder.scala b/src/main/scala/gs/std/v0/Decoder.scala new file mode 100644 index 0000000..2570ab8 --- /dev/null +++ b/src/main/scala/gs/std/v0/Decoder.scala @@ -0,0 +1,13 @@ +package gs.std.v0 + +/** + * Interface for byte decoding from encoded String formats. + */ +trait Decoder[-A <: EncodedString]: + /** + * Decode an input string to an array of bytes. + * + * @param input The input to decode. + * @return The decoded byte array. + */ + def decode(input: A): Array[Byte] diff --git a/src/main/scala/gs/std/v0/Encode.scala b/src/main/scala/gs/std/v0/Encode.scala new file mode 100644 index 0000000..f1d1222 --- /dev/null +++ b/src/main/scala/gs/std/v0/Encode.scala @@ -0,0 +1,58 @@ +package gs.std.v0 + +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets + +/** Helper functions for encoding data. + */ +object Encode: + + /** Encode an array of bytes using base64. + * + * @param input + * The bytes to encode. + * @return + * The base64 string. + */ + def base64(input: Array[Byte]): B64 = Base64Encoder.encode(input) + + /** Encode a string using base64. + * + * @param input + * The string to encode. + * @param charset + * The character set of the input string. + * @return + * The base64 string. + */ + def base64( + input: String, + charset: Charset = StandardCharsets.UTF_8 + ): B64 = + Base64Encoder.encode(input, charset) + + /** Encode an array of bytes using hexadecimal. + * + * @param input + * The bytes to encode. + * @return + * The hexadecimal string. + */ + def hex(input: Array[Byte]): Hex = HexEncoder.encode(input) + + /** Encode a string using hexadecimal. + * + * @param input + * The string to encode. + * @param charset + * The character set of the input string. + * @return + * The hexadecimal string. + */ + def hex( + input: String, + charset: Charset = StandardCharsets.UTF_8 + ): Hex = + HexEncoder.encode(input, charset) + +end Encode diff --git a/src/main/scala/gs/std/v0/EncodedString.scala b/src/main/scala/gs/std/v0/EncodedString.scala new file mode 100644 index 0000000..840b031 --- /dev/null +++ b/src/main/scala/gs/std/v0/EncodedString.scala @@ -0,0 +1,162 @@ +package gs.std.v0 + +/** Represents string-encoded data. + * + * See: + * - [[B64]] + * - [[Hex]] + */ +trait EncodedString: + /** @return + * The encoded data (expressed as a string). + */ + def data: String + + /** @return + * Decode the data to a byte array. + */ + def decode(): Array[Byte] + + /** Represents Base64-encoded data. + * + * @param data + * The encoded data. + */ + +/** Represents Base64-encoded data. + * + * @param data + * The encoded data. + */ +final class B64( + val data: String +) extends EncodedString: + /** @inheritDocs + */ + def decode(): Array[Byte] = Base64Decoder.decode(this) + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: B64 => data == other.data + + /** @inheritDocs + */ + override def hashCode(): Int = data.hashCode() + + /** @inheritDocs + */ + override def toString(): String = data + +object B64: + + /** Instantiate [[B64]] from the given string. Assumes that the input is + * base64-encoded. + * + * This function does NOT validate the input. + * + * @param data + * The encoded data. + * @return + * The new [[B64]] instance. + */ + def apply( + data: String + ): B64 = new B64(data) + + given CanEqual[B64, B64] = CanEqual.derived + +end B64 + +/** Represents Base64-url-encoded data. + * + * @param data + * The encoded data. + */ +final class B64Url( + val data: String +) extends EncodedString: + /** @inheritDocs + */ + def decode(): Array[Byte] = Base64Decoder.decodeUrl(this) + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: B64Url => data == other.data + + /** @inheritDocs + */ + override def hashCode(): Int = data.hashCode() + + /** @inheritDocs + */ + override def toString(): String = data + +object B64Url: + + /** Instantiate [[B64Url]] from the given string. Assumes that the input is + * base64-encoded. + * + * This function does NOT validate the input. + * + * @param data + * The encoded data. + * @return + * The new [[B64Url]] instance. + */ + def apply( + data: String + ): B64Url = new B64Url(data) + + given CanEqual[B64Url, B64Url] = CanEqual.derived + +end B64Url + +/** Represents Hex-encoded data. + * + * @param data + * The encoded data. + */ +final class Hex( + val data: String +) extends EncodedString: + /** @inheritDocs + */ + def decode(): Array[Byte] = HexDecoder.decode(this) + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: Hex => data == other.data + + /** @inheritDocs + */ + override def hashCode(): Int = data.hashCode() + + /** @inheritDocs + */ + override def toString(): String = data + +object Hex: + + /** Instantiate [[Hex]] from the given string. Assumes that the input is + * hex-encoded. + * + * This function does NOT validate the input. + * + * @param data + * The encoded data. + * @return + * The new [[Hex]] instance. + */ + def apply( + data: String + ): Hex = new Hex(data) + + given CanEqual[Hex, Hex] = CanEqual.derived + +end Hex diff --git a/src/main/scala/gs/std/v0/Encoder.scala b/src/main/scala/gs/std/v0/Encoder.scala new file mode 100644 index 0000000..89dbc6d --- /dev/null +++ b/src/main/scala/gs/std/v0/Encoder.scala @@ -0,0 +1,44 @@ +package gs.std.v0 + +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets + +/** Interface for byte encoding to String formats. + */ +trait Encoder[+A <: EncodedString]: + /** Encode an array of bytes as a string. + * + * @param input + * The bytes to encode. + * @return + * The encoded string. + */ + def encode(input: Array[Byte]): A + + /** Encode a string as a string. + * + * @param input + * The string to encode. + * @param charset + * The character set of the input string. + * @return + * The encoded string. + */ + def encode( + input: String, + charset: Charset = StandardCharsets.UTF_8 + ): A = encode(input.getBytes(charset)) + +object Encoder: + + /** @return + * The [[Base64Encoder]], typed to `Encoder[Encoded]`. + */ + def base64(): Encoder[EncodedString] = Base64Encoder + + /** @return + * The [[HexEncoder]], typed to `Encoder[Encoded]`. + */ + def hex(): Encoder[EncodedString] = HexEncoder + +end Encoder diff --git a/src/main/scala/gs/std/v0/HexDecoder.scala b/src/main/scala/gs/std/v0/HexDecoder.scala new file mode 100644 index 0000000..d5159a6 --- /dev/null +++ b/src/main/scala/gs/std/v0/HexDecoder.scala @@ -0,0 +1,23 @@ +package gs.std.v0 + +import java.util.HexFormat + +/** Implementation of [[Decoder]] for Hexadecimal strings. + */ +object HexDecoder extends Decoder[Hex]: + private lazy val h: HexFormat = HexFormat.of() + + /** @inheritDocs + */ + override def decode(input: Hex): Array[Byte] = + h.parseHex(input.data) + + /** Decode some arbitrary string data. + * + * @param input + * The data to decode. + * @return + * The decoded bytes. + */ + def decodeUnsafe(input: String): Array[Byte] = + h.parseHex(input) diff --git a/src/main/scala/gs/std/v0/HexEncoder.scala b/src/main/scala/gs/std/v0/HexEncoder.scala new file mode 100644 index 0000000..c6960c1 --- /dev/null +++ b/src/main/scala/gs/std/v0/HexEncoder.scala @@ -0,0 +1,13 @@ +package gs.std.v0 + +import java.util.HexFormat + +/** Implementation of [[Encoder]] for Hexadecimal strings. + */ +object HexEncoder extends Encoder[Hex]: + private lazy val h: HexFormat = HexFormat.of() + + /** @inheritDocs + */ + override def encode(input: Array[Byte]): Hex = + Hex(h.formatHex(input)) diff --git a/src/main/scala/gs/std/v0/MD5.scala b/src/main/scala/gs/std/v0/MD5.scala new file mode 100644 index 0000000..fbe5f60 --- /dev/null +++ b/src/main/scala/gs/std/v0/MD5.scala @@ -0,0 +1,105 @@ +package gs.std.v0 + +import java.security.MessageDigest +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets + +/** + * Opaque type representing a MD5 hash. + */ +opaque type MD5 = Array[Byte] + +/** + * Opaque type representing a MD5 hash. + */ +object MD5: + + /** + * MD5 hashes are exactly 16 bytes. + */ + final val NumberOfBytes: ByteCount = ByteCount(16) + + /** + * The algorithm name is "MD5". + */ + final val Algorithm: String = "MD5" + + /** + * Instantiate a [[MD5]] from the given byte array. This function does not + * know whether the array is actually a calculated hash. + * + * Typically used for loading pre-validated hashes (e.g. from a database). + * + * @param bytes The bytes - must contain exactly 16 bytes. + * @return The new [[MD5]] instance. + */ + def fromBytes(bytes: Array[Byte]): MD5 = + if NumberOfBytes.equal(bytes.length) then + bytes + else + throw IllegalArgumentException(s"MD5 values must be exactly $NumberOfBytes bytes.") + + /** + * Calculate the MD5 hash for the given byte array. + * + * @param data The byte array. + * @return The calculated [[MD5]]. + */ + def calculate(data: Array[Byte]): MD5 = + MessageDigest.getInstance(Algorithm).digest(data) + + /* + * Calculate the MD5 hash for the given string. + * + * @param data The string data. + * @param charset The character set of the string. Defaults to UTF-8. + * @return The calculated [[MD5]]. + */ + def calculate(data: String, charset: Charset = StandardCharsets.UTF_8): MD5 = + calculate(data.getBytes(charset)) + + extension (md5: MD5) + /** + * @return The underlying byte array. + */ + def unwrap(): Array[Byte] = md5 + + /** + * @return The underlying byte array. + */ + def toBytes(): Array[Byte] = md5 + + /** + * Get the byte at the given index (0 to 15). + * + * Throws an exception if an out-of-bound index is given. + * + * @param index The byte index (0 to 15). + * @return The byte at the specified index. + */ + def getByte(index: Int): Byte = + if index < 0 || index >= NumberOfBytes.unwrap() then + throw IndexOutOfBoundsException(s"Index $index out of MD5 bound of $NumberOfBytes bytes.") + else + md5.apply(index) + + /** + * Determine if this hash is the same as some other hash. Compares each byte + * in order. + * + * @param other The [[MD5]] to compare against. + * @return True if the hashes are identical, false otherwise. + */ + def isSame(other: MD5): Boolean = md5.sameElements(other) + + /** + * @return This hash encoded to a Base64 string. + */ + def base64(): EncodedString = Base64Encoder.encode(md5) + + /** + * @return This hash encoded to a Hexadecimal string. + */ + def hex(): EncodedString = HexEncoder.encode(md5) + +end MD5 diff --git a/src/main/scala/gs/std/v0/Nat.scala b/src/main/scala/gs/std/v0/Nat.scala new file mode 100644 index 0000000..07b5d78 --- /dev/null +++ b/src/main/scala/gs/std/v0/Nat.scala @@ -0,0 +1,81 @@ +package gs.std.v0 + +/** + * Opaque type for the natural numbers (including 0). + */ +opaque type Nat = Int + +/** + * Opaque type for the natural numbers (including 0). + */ +object Nat: + + sealed trait Invalid + object Invalid extends Invalid + + /** + * The number 0. + */ + final val Zero: Nat = 0 + + /** + * The number 1. + */ + final val One: Nat = 1 + + /** + * Express the given integer as a natural number. + * + * Throws an `IllegalArgumentException` if a negative value is given as input. + * + * @param value The input integer. + * @return The [[Nat]] instance. + */ + def apply(value: Int): Nat = + if value >= 0 then + value + else + throw new IllegalArgumentException("Nat values must be 0 or greater.") + + def validate(value: Int): Either[Invalid, Nat] = + if value >= 0 then Right(value) else Left(Invalid) + + given CanEqual[Nat, Nat] = CanEqual.derived + + given Ordering[Nat] with + /** @inheritDocs */ + def compare(x: Nat, y: Nat): Int = x - y + + extension (nat: Nat) + /** + * @return The underlying integer. + */ + def unwrap(): Int = nat + + /** + * @return The next integer. + */ + def next(): Nat = nat + 1 + + /** + * @return The next integer. + */ + def increment(): Nat = nat + 1 + + /** + * Add some natural number to this one. + * + * @param that The number to add. + * @return The sum of the numbers. + */ + def +(that: Nat): Nat = nat + that + + /** + * Multiply this natural number by some other natural number. + * + * @param that The number to multiply by. + * @return The product of the numbers. + */ + def *(that: Nat): Nat = nat * that + +end Nat diff --git a/src/main/scala/gs/std/v0/SHA256.scala b/src/main/scala/gs/std/v0/SHA256.scala new file mode 100644 index 0000000..c14df04 --- /dev/null +++ b/src/main/scala/gs/std/v0/SHA256.scala @@ -0,0 +1,105 @@ +package gs.std.v0 + +import java.security.MessageDigest +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets + +/** + * Opaque type representing a SHA-256 hash. + */ +opaque type SHA256 = Array[Byte] + +/** + * Opaque type representing a SHA-256 hash. + */ +object SHA256: + + /** + * SHA-256 hashes are exactly 32 bytes. + */ + final val NumberOfBytes: ByteCount = ByteCount(32) + + /** + * The algorithm name is "SHA-256". + */ + final val Algorithm: String = "SHA-256" + + /** + * Instantiate a [[SHA256]] from the given byte array. This function does not + * know whether the array is actually a calculated hash. + * + * Typically used for loading pre-validated hashes (e.g. from a database). + * + * @param bytes The bytes - must contain exactly 32 bytes. + * @return The new [[SHA256]] instance. + */ + def fromBytes(bytes: Array[Byte]): SHA256 = + if NumberOfBytes.equal(bytes.length) then + bytes + else + throw IllegalArgumentException(s"SHA-256 values must be exactly $NumberOfBytes bytes.") + + /** + * Calculate the SHA-256 hash for the given byte array. + * + * @param data The byte array. + * @return The calculated [[SHA256]]. + */ + def calculate(data: Array[Byte]): SHA256 = + MessageDigest.getInstance(Algorithm).digest(data) + + /** + * Calculate the SHA-256 hash for the given string. + * + * @param data The string data. + * @param charset The character set of the string. Defaults to UTF-8. + * @return The calculated [[SHA256]]. + */ + def calculate(data: String, charset: Charset = StandardCharsets.UTF_8): SHA256 = + calculate(data.getBytes(charset)) + + extension (sha: SHA256) + /** + * @return The underlying byte array. + */ + def unwrap(): Array[Byte] = sha + + /** + * @return The underlying byte array. + */ + def toBytes(): Array[Byte] = sha + + /** + * Get the byte at the given index (0 to 31). + * + * Throws an exception if an out-of-bound index is given. + * + * @param index The byte index (0 to 31). + * @return The byte at the specified index. + */ + def getByte(index: Int): Byte = + if index < 0 || index >= NumberOfBytes.unwrap() then + throw IndexOutOfBoundsException(s"Index $index out of SHA-256 bound of $NumberOfBytes bytes.") + else + sha.apply(index) + + /** + * Determine if this hash is the same as some other hash. Compares each byte + * in order. + * + * @param other The [[SHA256]] to compare against. + * @return True if the hashes are identical, false otherwise. + */ + def isSame(other: SHA256): Boolean = sha.sameElements(other) + + /** + * @return This hash encoded to a Base64 string. + */ + def base64(): EncodedString = Base64Encoder.encode(sha) + + /** + * @return This hash encoded to a Hexadecimal string. + */ + def hex(): EncodedString = HexEncoder.encode(sha) + +end SHA256 diff --git a/src/main/scala/gs/std/v0/Size.scala b/src/main/scala/gs/std/v0/Size.scala new file mode 100644 index 0000000..0de45fa --- /dev/null +++ b/src/main/scala/gs/std/v0/Size.scala @@ -0,0 +1,97 @@ +package gs.std.v0 + +/** + * Opaque type for collection sizes. Values are guaranteed to be 0 or greater. + */ +opaque type Size = Int + +/** + * Opaque type for collection sizes. Values are guaranteed to be 0 or greater. + */ +object Size: + + sealed trait Invalid + object Invalid extends Invalid + + /** + * The size 0. + */ + final val Zero: Size = 0 + + /** + * The size 1. + */ + final val One: Size = 1 + + /** + * Express the given integer as a size. + * + * Throws an `IllegalArgumentException` if a negative value is given as input. + * + * @param value The input integer. + * @return The [[Size]] instance. + */ + def apply(value: Int): Size = + if value >= 0 then + value + else + throw new IllegalArgumentException("Size values must be 0 or greater.") + + def validate(value: Int): Either[Invalid, Size] = + if value >= 0 then Right(value) else Left(Invalid) + + /** + * Express the size of any collection. + * + * @param iter The collection. + * @return The size of the collection. + */ + def of(iter: Iterable[?]): Size = iter.size + + /** + * Express the size of any array. + * + * @param arr The array. + * @return The size of the array. + */ + def of(arr: Array[?]): Size = arr.length + + given CanEqual[Size, Size] = CanEqual.derived + + given Ordering[Size] with + /** @inheritDocs */ + def compare(x: Size, y: Size): Int = x - y + + extension (size: Size) + /** + * @return The underlying integer. + */ + def unwrap(): Int = size + + /** + * @return The next value. + */ + def next(): Size = size + 1 + + /** + * @return The next value. + */ + def increment(): Size = size + 1 + + /** + * Add some size to this one. + * + * @param that The number to add. + * @return The sum of the numbers. + */ + def +(that: Size): Size = size + that + + /** + * Multiply this size by some other size. + * + * @param that The number to multiply by. + * @return The product of the numbers. + */ + def *(that: Size): Size = size * that + +end Size diff --git a/src/main/scala/gs/std/v0/UpdatedAt.scala b/src/main/scala/gs/std/v0/UpdatedAt.scala new file mode 100644 index 0000000..cf32ef8 --- /dev/null +++ b/src/main/scala/gs/std/v0/UpdatedAt.scala @@ -0,0 +1,39 @@ +package gs.std.v0 + +import java.time.Instant + +/** + * Opaque type that represents the instant something was updated. + */ +opaque type UpdatedAt = Instant + +/** + * Opaque type that represents the instant something was updated. + */ +object UpdatedAt: + + /** + * Instantiate a new [[UpdatedAt]] from the given `java.time.Instant`. + * + * @param value The value to semantically represent. + * @return The new [[UpdatedAt]]. + */ + def apply(value: Instant): UpdatedAt = value + + given CanEqual[UpdatedAt, UpdatedAt] = CanEqual.derived + + given Ordering[UpdatedAt] = Ordering[Instant] + + extension (updatedAt: Instant) + /** + * @return The underlying `java.time.Instant`. + */ + def unwrap(): Instant = updatedAt + + /** + * @return The underlying `java.time.Instant`. + */ + def toInstant(): Instant = updatedAt + +end UpdatedAt +