├── .circleci └── config.yml ├── .config └── rubocop │ └── config.yml ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .reek.yml ├── .ruby-version ├── CITATION.cff ├── Gemfile ├── LICENSE.adoc ├── README.adoc ├── Rakefile ├── bin ├── console ├── rake ├── rspec ├── rubocop └── setup ├── lib ├── versionaire.rb └── versionaire │ ├── cast.rb │ ├── error.rb │ ├── extensions │ └── option_parser.rb │ ├── function.rb │ └── version.rb ├── spec ├── lib │ └── versionaire │ │ ├── cast_spec.rb │ │ ├── extensions │ │ └── option_parser_spec.rb │ │ ├── function_spec.rb │ │ ├── version_spec.rb │ │ └── versionaire_spec.rb └── spec_helper.rb └── versionaire.gemspec /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | jobs: 3 | build: 4 | working_directory: ~/project 5 | docker: 6 | - image: bkuhlmann/alpine-ruby:latest 7 | steps: 8 | - checkout 9 | 10 | - restore_cache: 11 | name: Gems Restore 12 | keys: 13 | - gem-cache-{{.Branch}}-{{checksum "Gemfile"}}-{{checksum "versionaire.gemspec"}} 14 | - gem-cache- 15 | 16 | - run: 17 | name: Gems Install 18 | command: | 19 | gem update --system 20 | bundle config set path "vendor/bundle" 21 | bundle install 22 | 23 | - save_cache: 24 | name: Gems Store 25 | key: gem-cache-{{.Branch}}-{{checksum "Gemfile"}}-{{checksum "versionaire.gemspec"}} 26 | paths: 27 | - vendor/bundle 28 | 29 | - run: 30 | name: Rake 31 | command: bundle exec rake 32 | 33 | - store_artifacts: 34 | name: SimpleCov Report 35 | path: ~/project/coverage 36 | destination: coverage 37 | -------------------------------------------------------------------------------- /.config/rubocop/config.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | caliber: config/all.yml 3 | 4 | Style/IpAddresses: 5 | Exclude: 6 | - "spec/lib/versionaire/versionaire_spec.rb" 7 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [bkuhlmann] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Why 2 | 3 | 4 | ## How 5 | 6 | 7 | ## Notes 8 | 9 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | 3 | 4 | ## Screenshots/Screencasts 5 | 6 | 7 | ## Details 8 | 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile.lock 4 | pkg 5 | tmp 6 | -------------------------------------------------------------------------------- /.reek.yml: -------------------------------------------------------------------------------- 1 | detectors: 2 | LongParameterList: 3 | enabled: false 4 | TooManyStatements: 5 | exclude: 6 | - Versionaire#Version 7 | UncommunicativeMethodName: 8 | exclude: 9 | - Kernel#Version 10 | - Versionaire#Version 11 | UtilityFunction: 12 | exclude: 13 | - Kernel#Version 14 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.4 2 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: 1.2.0 2 | message: Please use the following metadata when citing this project in your work. 3 | title: Versionaire 4 | abstract: An immutable, thread-safe, and strict semantic version type. 5 | version: 14.2.0 6 | license: Hippocratic-2.1 7 | date-released: 2025-04-18 8 | authors: 9 | - family-names: Kuhlmann 10 | given-names: Brooke 11 | affiliation: Alchemists 12 | orcid: https://orcid.org/0000-0002-5810-6268 13 | keywords: 14 | - ruby 15 | - semantic versioning 16 | repository-code: https://github.com/bkuhlmann/versionaire 17 | repository-artifact: https://rubygems.org/gems/versionaire 18 | url: https://alchemists.io/projects/versionaire 19 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ruby file: ".ruby-version" 4 | 5 | source "https://rubygems.org" 6 | 7 | gemspec 8 | 9 | group :quality do 10 | gem "caliber", "~> 0.79" 11 | gem "git-lint", "~> 9.0" 12 | gem "reek", "~> 6.5", require: false 13 | gem "simplecov", "~> 0.22", require: false 14 | end 15 | 16 | group :development do 17 | gem "rake", "~> 13.2" 18 | end 19 | 20 | group :test do 21 | gem "rspec", "~> 3.13" 22 | end 23 | 24 | group :tools do 25 | gem "amazing_print", "~> 1.8" 26 | gem "debug", "~> 1.10" 27 | gem "irb-kit", "~> 1.1" 28 | gem "repl_type_completor", "~> 0.1" 29 | end 30 | -------------------------------------------------------------------------------- /LICENSE.adoc: -------------------------------------------------------------------------------- 1 | = Hippocratic License 2 | 3 | Version: 2.1.0. 4 | 5 | Purpose. The purpose of this License is for the Licensor named above to 6 | permit the Licensee (as defined below) broad permission, if consistent 7 | with Human Rights Laws and Human Rights Principles (as each is defined 8 | below), to use and work with the Software (as defined below) within the 9 | full scope of Licensor’s copyright and patent rights, if any, in the 10 | Software, while ensuring attribution and protecting the Licensor from 11 | liability. 12 | 13 | Permission and Conditions. The Licensor grants permission by this 14 | license ("License"), free of charge, to the extent of Licensor’s 15 | rights under applicable copyright and patent law, to any person or 16 | entity (the "Licensee") obtaining a copy of this software and 17 | associated documentation files (the "Software"), to do everything with 18 | the Software that would otherwise infringe (i) the Licensor’s copyright 19 | in the Software or (ii) any patent claims to the Software that the 20 | Licensor can license or becomes able to license, subject to all of the 21 | following terms and conditions: 22 | 23 | * Acceptance. This License is automatically offered to every person and 24 | entity subject to its terms and conditions. Licensee accepts this 25 | License and agrees to its terms and conditions by taking any action with 26 | the Software that, absent this License, would infringe any intellectual 27 | property right held by Licensor. 28 | * Notice. Licensee must ensure that everyone who gets a copy of any part 29 | of this Software from Licensee, with or without changes, also receives 30 | the License and the above copyright notice (and if included by the 31 | Licensor, patent, trademark and attribution notice). Licensee must cause 32 | any modified versions of the Software to carry prominent notices stating 33 | that Licensee changed the Software. For clarity, although Licensee is 34 | free to create modifications of the Software and distribute only the 35 | modified portion created by Licensee with additional or different terms, 36 | the portion of the Software not modified must be distributed pursuant to 37 | this License. If anyone notifies Licensee in writing that Licensee has 38 | not complied with this Notice section, Licensee can keep this License by 39 | taking all practical steps to comply within 30 days after the notice. If 40 | Licensee does not do so, Licensee’s License (and all rights licensed 41 | hereunder) shall end immediately. 42 | * Compliance with Human Rights Principles and Human Rights Laws. 43 | [arabic] 44 | . Human Rights Principles. 45 | [loweralpha] 46 | .. Licensee is advised to consult the articles of the United Nations 47 | Universal Declaration of Human Rights and the United Nations Global 48 | Compact that define recognized principles of international human rights 49 | (the "Human Rights Principles"). Licensee shall use the Software in a 50 | manner consistent with Human Rights Principles. 51 | .. Unless the Licensor and Licensee agree otherwise, any dispute, 52 | controversy, or claim arising out of or relating to (i) Section 1(a) 53 | regarding Human Rights Principles, including the breach of Section 1(a), 54 | termination of this License for breach of the Human Rights Principles, 55 | or invalidity of Section 1(a) or (ii) a determination of whether any Law 56 | is consistent or in conflict with Human Rights Principles pursuant to 57 | Section 2, below, shall be settled by arbitration in accordance with the 58 | Hague Rules on Business and Human Rights Arbitration (the "Rules"); 59 | provided, however, that Licensee may elect not to participate in such 60 | arbitration, in which event this License (and all rights licensed 61 | hereunder) shall end immediately. The number of arbitrators shall be one 62 | unless the Rules require otherwise. 63 | + 64 | Unless both the Licensor and Licensee agree to the contrary: (1) All 65 | documents and information concerning the arbitration shall be public and 66 | may be disclosed by any party; (2) The repository referred to under 67 | Article 43 of the Rules shall make available to the public in a timely 68 | manner all documents concerning the arbitration which are communicated 69 | to it, including all submissions of the parties, all evidence admitted 70 | into the record of the proceedings, all transcripts or other recordings 71 | of hearings and all orders, decisions and awards of the arbitral 72 | tribunal, subject only to the arbitral tribunal’s powers to take such 73 | measures as may be necessary to safeguard the integrity of the arbitral 74 | process pursuant to Articles 18, 33, 41 and 42 of the Rules; and (3) 75 | Article 26(6) of the Rules shall not apply. 76 | . Human Rights Laws. The Software shall not be used by any person or 77 | entity for any systems, activities, or other uses that violate any Human 78 | Rights Laws. "Human Rights Laws" means any applicable laws, 79 | regulations, or rules (collectively, "Laws") that protect human, 80 | civil, labor, privacy, political, environmental, security, economic, due 81 | process, or similar rights; provided, however, that such Laws are 82 | consistent and not in conflict with Human Rights Principles (a dispute 83 | over the consistency or a conflict between Laws and Human Rights 84 | Principles shall be determined by arbitration as stated above). Where 85 | the Human Rights Laws of more than one jurisdiction are applicable or in 86 | conflict with respect to the use of the Software, the Human Rights Laws 87 | that are most protective of the individuals or groups harmed shall 88 | apply. 89 | . Indemnity. Licensee shall hold harmless and indemnify Licensor (and 90 | any other contributor) against all losses, damages, liabilities, 91 | deficiencies, claims, actions, judgments, settlements, interest, awards, 92 | penalties, fines, costs, or expenses of whatever kind, including 93 | Licensor’s reasonable attorneys’ fees, arising out of or relating to 94 | Licensee’s use of the Software in violation of Human Rights Laws or 95 | Human Rights Principles. 96 | * Failure to Comply. Any failure of Licensee to act according to the 97 | terms and conditions of this License is both a breach of the License and 98 | an infringement of the intellectual property rights of the Licensor 99 | (subject to exceptions under Laws, e.g., fair use). In the event of a 100 | breach or infringement, the terms and conditions of this License may be 101 | enforced by Licensor under the Laws of any jurisdiction to which 102 | Licensee is subject. Licensee also agrees that the Licensor may enforce 103 | the terms and conditions of this License against Licensee through 104 | specific performance (or similar remedy under Laws) to the extent 105 | permitted by Laws. For clarity, except in the event of a breach of this 106 | License, infringement, or as otherwise stated in this License, Licensor 107 | may not terminate this License with Licensee. 108 | * Enforceability and Interpretation. If any term or provision of this 109 | License is determined to be invalid, illegal, or unenforceable by a 110 | court of competent jurisdiction, then such invalidity, illegality, or 111 | unenforceability shall not affect any other term or provision of this 112 | License or invalidate or render unenforceable such term or provision in 113 | any other jurisdiction; provided, however, subject to a court 114 | modification pursuant to the immediately following sentence, if any term 115 | or provision of this License pertaining to Human Rights Laws or Human 116 | Rights Principles is deemed invalid, illegal, or unenforceable against 117 | Licensee by a court of competent jurisdiction, all rights in the 118 | Software granted to Licensee shall be deemed null and void as between 119 | Licensor and Licensee. Upon a determination that any term or provision 120 | is invalid, illegal, or unenforceable, to the extent permitted by Laws, 121 | the court may modify this License to affect the original purpose that 122 | the Software be used in compliance with Human Rights Principles and 123 | Human Rights Laws as closely as possible. The language in this License 124 | shall be interpreted as to its fair meaning and not strictly for or 125 | against any party. 126 | * Disclaimer. TO THE FULL EXTENT ALLOWED BY LAW, THIS SOFTWARE COMES 127 | "AS IS," WITHOUT ANY WARRANTY, EXPRESS OR IMPLIED, AND LICENSOR AND 128 | ANY OTHER CONTRIBUTOR SHALL NOT BE LIABLE TO ANYONE FOR ANY DAMAGES OR 129 | OTHER LIABILITY ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE 130 | OR THIS LICENSE, UNDER ANY KIND OF LEGAL CLAIM. 131 | 132 | This Hippocratic License is an link:https://ethicalsource.dev[Ethical Source license] and is offered 133 | for use by licensors and licensees at their own risk, on an "AS IS" basis, and with no warranties 134 | express or implied, to the maximum extent permitted by Laws. 135 | -------------------------------------------------------------------------------- /README.adoc: -------------------------------------------------------------------------------- 1 | :toc: macro 2 | :toclevels: 5 3 | :figure-caption!: 4 | 5 | :option_parser_link: link:https://alchemists.io/articles/ruby_option_parser[OptionParser] 6 | :semver_link: link:https://semver.org[Semantic Versioning] 7 | :strict_semver_link: link:https://alchemists.io/articles/strict_semantic_versioning[Strict Semantic Versioning] 8 | 9 | = Versionaire 10 | 11 | Ruby doesn't provide a primitive version type by default so Versionaire fills this gap by providing immutable and thread-safe {strict_semver_link} so you can leverage versions within your applications. This new `Version` type behaves and feels a lot like other primitives (i.e. `String`, `Array`, `Hash`, `Proc`, etc) and can be cast/converted from other primitives. 12 | 13 | toc::[] 14 | 15 | == Features 16 | 17 | * Provides {strict_semver_link} which means `..`. 18 | * Provides immutable, thread-safe version instances. 19 | * Converts (casts) from a `String`, `Array`, `Hash`, `Proc`, or `Version` to a `Version`. 20 | * Disallows `..-` usage even though {semver_link} suggests you _may_ use pre-release information. 21 | * Disallows `..+` usage even though {semver_link} suggests you _may_ use build metadata. 22 | 23 | == Requirements 24 | 25 | . https://www.ruby-lang.org[Ruby]. 26 | 27 | == Setup 28 | 29 | To install _with_ security, run: 30 | 31 | [source,bash] 32 | ---- 33 | # 💡 Skip this line if you already have the public certificate installed. 34 | gem cert --add <(curl --compressed --location https://alchemists.io/gems.pem) 35 | gem install versionaire --trust-policy HighSecurity 36 | ---- 37 | 38 | To install _without_ security, run: 39 | 40 | [source,bash] 41 | ---- 42 | gem install versionaire 43 | ---- 44 | 45 | You can also add the gem directly to your project: 46 | 47 | [source,bash] 48 | ---- 49 | bundle add versionaire 50 | ---- 51 | 52 | Once the gem is installed, you only need to require it: 53 | 54 | [source,ruby] 55 | ---- 56 | require "versionaire" 57 | ---- 58 | 59 | == Usage 60 | 61 | === Initialization 62 | 63 | A new version can be initialized in a variety of ways: 64 | 65 | [source,ruby] 66 | ---- 67 | Versionaire::Version.new # "0.0.0" 68 | Versionaire::Version[major: 1] # "1.0.0" 69 | Versionaire::Version[major: 1, minor: 2] # "1.2.0" 70 | Versionaire::Version[major: 1, minor: 2, patch: 3] # "1.2.3" 71 | ---- 72 | 73 | === Equality 74 | 75 | ==== Value (`+#==+`) 76 | 77 | Equality is determined by the state of the object. This means that a version is equal to another version as long as all of the values (i.e. state) are equal to each other. Example: 78 | 79 | [source,ruby] 80 | ---- 81 | version_a = Versionaire::Version[major: 1] 82 | version_b = Versionaire::Version[major: 2] 83 | version_c = Versionaire::Version[major: 1] 84 | 85 | version_a == version_a # true 86 | version_a == version_b # false 87 | version_a == version_c # true 88 | ---- 89 | 90 | Knowing this, versions can be compared against one another too: 91 | 92 | [source,ruby] 93 | ---- 94 | version_a > version_b # false 95 | version_a < version_b # true 96 | version_a.between? version_c, version_b # true 97 | ---- 98 | 99 | ==== Hash (`#eql?`) 100 | 101 | Behaves exactly as `#==`. 102 | 103 | ==== Case (`#===`) 104 | 105 | Behaves exactly as `#==`. 106 | 107 | ==== Identity (`#equal?`) 108 | 109 | Works like any other standard Ruby object where an object is equal only to itself. 110 | 111 | [source,ruby] 112 | ---- 113 | version_a = Versionaire::Version[major: 1] 114 | version_b = Versionaire::Version[major: 2] 115 | version_c = Versionaire::Version[major: 1] 116 | 117 | version_a.equal? version_a # true 118 | version_a.equal? version_b # false 119 | version_a.equal? version_c # false 120 | ---- 121 | 122 | === Conversions 123 | 124 | ==== Function 125 | 126 | Use the `Versionaire::Version` function to explicitly cast to a version: 127 | 128 | [source,ruby] 129 | ---- 130 | version = Versionaire::Version[major: 1] 131 | 132 | Versionaire::Version "1.0.0" 133 | Versionaire::Version [1, 0, 0] 134 | Versionaire::Version major: 1, minor: 0, patch: 0 135 | Versionaire::Version version 136 | ---- 137 | 138 | Each of these conversions will result in a version object that represents "`1.0.0`". 139 | 140 | When attempting to convert an unsupported type, a `Versionaire::Error` exception will be thrown. 141 | 142 | ==== Refinement 143 | 144 | Building upon the above examples, a more elegant solution is to use a link:https://alchemists.io/articles/ruby_refinements[refinement]: 145 | 146 | [source,ruby] 147 | ---- 148 | using Versionaire::Cast 149 | 150 | version = Versionaire::Version[major: 1] 151 | 152 | Version "1.0.0" 153 | Version [1, 0, 0] 154 | Version major: 1, minor: 0, patch: 0 155 | Version version 156 | ---- 157 | 158 | By adding `using Versionaire::Cast` to your implementation, this allows Versionaire to refine 159 | `Kernel` so you have a top-level `Version` conversion function much like Kernel's native support for 160 | `Integer`, `String`, `Array`, `Hash`, etc. The benefit to this approach is to reduce the amount of 161 | typing so you don't pollute your entire object space, like a monkey patch, while providing an idiomatic approach to casting like any other primitive. 162 | 163 | ==== Implicit 164 | 165 | Implicit conversion to a `String` is supported: 166 | 167 | [source,ruby] 168 | ---- 169 | "1.0.0".match Versionaire::Version[major: 1] # 170 | ---- 171 | 172 | ==== Explicit 173 | 174 | Explicit conversion to a `String`, `Array`, `Hash`, or `Proc` is supported: 175 | 176 | [source,ruby] 177 | ---- 178 | version = Versionaire::Version.new 179 | 180 | version.to_s # "0.0.0" 181 | version.to_a # [0, 0, 0] 182 | version.to_h # {major: 0, minor: 0, patch: 0} 183 | version.to_proc # # 184 | ---- 185 | 186 | To elaborate on procs, this means the following is possible where you might want to collect all minor verions values or make use of version information in other useful ways: 187 | 188 | [source,ruby] 189 | ---- 190 | using Versionaire::Cast 191 | 192 | version = Version "1.2.3" 193 | 194 | version.to_proc.call :major # 1 195 | [version, version, version].map(&:minor) # [2, 2, 2] 196 | ---- 197 | 198 | === Inspections 199 | 200 | You can inspect a version which is the equivalent of an escaped string representation. Example: 201 | 202 | [source,ruby] 203 | ---- 204 | using Versionaire::Cast 205 | 206 | Version("1.2.3").inspect # "\"1.2.3\"" 207 | ---- 208 | 209 | === Comparisons 210 | 211 | All versions are comparable which means any of the operators from the `+Comparable+` module will 212 | work. Example: 213 | 214 | [source,ruby] 215 | ---- 216 | version_1 = Versionaire::Version "1.0.0" 217 | version_2 = Versionaire::Version "2.0.0" 218 | 219 | version_1 < version_2 # true 220 | version_1 <= version_2 # true 221 | version_1 == version_2 # false (see Equality section above for details) 222 | version_1 > version_2 # false 223 | version_1 >= version_2 # false 224 | version_1.between? version_1, version_2 # true 225 | version_1.clamp version_1, version_2 # version_1 (added in Ruby 2.4.0) 226 | ---- 227 | 228 | === Bumping 229 | 230 | Versions can be bumped to next logical version with respect current version. Example: 231 | 232 | [source,ruby] 233 | ---- 234 | version = Versionaire::Version.new # "0.0.0" 235 | version.bump :patch # "0.0.1" 236 | version.bump :minor # "0.1.0" 237 | version.bump :major # "1.0.0" 238 | 239 | Versionaire::Version[major: 1, minor: 2, patch: 3].bump :major # "2.0.0" 240 | Versionaire::Version[major: 1, minor: 2, patch: 3].bump :minor # "1.3.0" 241 | Versionaire::Version[major: 1, minor: 2, patch: 3].bump :patch # "1.2.4" 242 | ---- 243 | 244 | You'll notice, when bumping the major or minor versions, lower precision gets zeroed out in order to provide the next logical version. 245 | 246 | === Math 247 | 248 | Versions can be added, subtracted, sequentially increased, or sequentially decreased from each 249 | other. 250 | 251 | ==== Addition 252 | 253 | Versions can be added together to produce a resulting version sum. 254 | 255 | [source,ruby] 256 | ---- 257 | version_1 = Versionaire::Version[major: 1, minor: 2, patch: 3] 258 | version_2 = Versionaire::Version[major: 2, minor: 5, patch: 7] 259 | version_1 + version_2 # "3.7.10" 260 | ---- 261 | 262 | ==== Subtraction 263 | 264 | Versions can be substracted from each other as long as there isn't a negative result. 265 | 266 | [source,ruby] 267 | ---- 268 | version_1 = Versionaire::Version[major: 1, minor: 2, patch: 3] 269 | version_2 = Versionaire::Version[major: 1, minor: 1, patch: 1] 270 | version_1 - version_2 # "0.1.2" 271 | 272 | version_1 = Versionaire::Version[major: 1] 273 | version_2 = Versionaire::Version[major: 5] 274 | version_1 - version_2 # Versionaire::Error 275 | ---- 276 | 277 | ==== Up 278 | 279 | Versions can be sequentially increased or given a specific version to jump to. 280 | 281 | [source,ruby] 282 | ---- 283 | version = Versionaire::Version[major: 1, minor: 1, patch: 1] 284 | version.up :major # => "2.1.1" 285 | version.up :major, 3 # => "4.1.1" 286 | version.up :minor # => "1.2.1" 287 | version.up :minor, 3 # => "1.4.1" 288 | version.up :patch # => "1.1.2" 289 | version.up :patch, 3 # => "1.1.4" 290 | ---- 291 | 292 | ==== Down 293 | 294 | Versions can be sequentially decreased or given a specific version to jump to as long as the result 295 | is not negative. 296 | 297 | [source,ruby] 298 | ---- 299 | version = Versionaire::Version[major: 5, minor: 5, patch: 5] 300 | version.down :major # => "4.5.5" 301 | version.down :major, 3 # => "2.5.5" 302 | version.down :minor # => "5.4.5" 303 | version.down :minor, 3 # => "5.2.5" 304 | version.down :patch # => "5.5.4" 305 | version.down :patch, 3 # => "5.5.2" 306 | version.down :major, 6 # => Versionaire::Error 307 | ---- 308 | 309 | === Extensions 310 | 311 | This project supports libraries which might desire native `Version` types. Each extension _must be 312 | explicitly required_ in order to be used since they are _optional_ by default. See below for 313 | details. 314 | 315 | ==== OptionParser 316 | 317 | {option_parser_link} is one of Ruby's link:https://stdgems.org[default gems] which can accept additional types not native to Ruby by default. To extend `OptionParser` with the `Version` type, all you need to do is add these two lines to your implementation: 318 | 319 | . `require "versionaire/extensions/option_parser"`: This will load dependencies and register the `Version` type with `OptionParser`. 320 | . `act.on "--tag VERSION", Versionaire::Version`: Specifying `Versionaire::Version` as the second argument will ensure `OptionParser` properly casts command line input as a `Version` type. 321 | 322 | Here's an example implementation that demonstrates full usage: 323 | 324 | [source,ruby] 325 | ---- 326 | require "versionaire/extensions/option_parser" 327 | 328 | options = {} 329 | 330 | parser = OptionParser.new do |act| 331 | act.on "--tag VERSION", Versionaire::Version, "Casts to version." do |value| 332 | options[:version] = value 333 | end 334 | end 335 | 336 | parser.parse %w[--tag 1.2.3] 337 | puts options 338 | ---- 339 | 340 | The above will ensure `--tag 1.2.3` is parsed as `{version: "1.2.3"}` within your `options` variable. Should `OptionParser` parse an invalid version, you'll get a `OptionParser::InvalidArgument` instead. 341 | 342 | == Development 343 | 344 | To contribute, run: 345 | 346 | [source,bash] 347 | ---- 348 | git clone https://github.com/bkuhlmann/versionaire 349 | cd versionaire 350 | bin/setup 351 | ---- 352 | 353 | You can also use the IRB console for direct access to all objects: 354 | 355 | [source,bash] 356 | ---- 357 | bin/console 358 | ---- 359 | 360 | == Tests 361 | 362 | To test, run: 363 | 364 | [source,bash] 365 | ---- 366 | bin/rake 367 | ---- 368 | 369 | == link:https://alchemists.io/policies/license[License] 370 | 371 | == link:https://alchemists.io/policies/security[Security] 372 | 373 | == link:https://alchemists.io/policies/code_of_conduct[Code of Conduct] 374 | 375 | == link:https://alchemists.io/policies/contributions[Contributions] 376 | 377 | == link:https://alchemists.io/policies/developer_certificate_of_origin[Developer Certificate of Origin] 378 | 379 | == link:https://alchemists.io/projects/versionaire/versions[Versions] 380 | 381 | == link:https://alchemists.io/community[Community] 382 | 383 | == Credits 384 | 385 | * Built with link:https://alchemists.io/projects/gemsmith[Gemsmith]. 386 | * Engineered by link:https://alchemists.io/team/brooke_kuhlmann[Brooke Kuhlmann]. 387 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "git/lint/rake/register" 5 | require "reek/rake/task" 6 | require "rspec/core/rake_task" 7 | require "rubocop/rake_task" 8 | 9 | Git::Lint::Rake::Register.call 10 | Reek::Rake::Task.new 11 | RSpec::Core::RakeTask.new { |task| task.verbose = false } 12 | RuboCop::RakeTask.new 13 | 14 | desc "Run code quality checks" 15 | task quality: %i[git_lint reek rubocop] 16 | 17 | task default: %i[quality spec] 18 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | Bundler.require :tools 6 | 7 | require "irb" 8 | require "versionaire" 9 | 10 | IRB.start __FILE__ 11 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | 6 | load Gem.bin_path "rake", "rake" 7 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | 6 | load Gem.bin_path "rspec-core", "rspec" 7 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | 6 | load Gem.bin_path "rubocop", "rubocop" 7 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "debug" 5 | require "fileutils" 6 | require "pathname" 7 | 8 | APP_ROOT = Pathname(__dir__).join("..").expand_path 9 | 10 | Runner = lambda do |*arguments, kernel: Kernel| 11 | kernel.system(*arguments) || kernel.abort("\nERROR: Command #{arguments.inspect} failed.") 12 | end 13 | 14 | FileUtils.chdir APP_ROOT do 15 | puts "Installing dependencies..." 16 | Runner.call "bundle install" 17 | end 18 | -------------------------------------------------------------------------------- /lib/versionaire.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "versionaire/cast" 4 | require "versionaire/error" 5 | require "versionaire/function" 6 | require "versionaire/version" 7 | 8 | module Versionaire 9 | DELIMITER = "." 10 | 11 | PATTERN = / 12 | \A( # Start of string and OR. 13 | \d* # Major only. 14 | | # OR pipe. 15 | \d+ # Major. 16 | #{DELIMITER}? # Delimiter. 17 | \d* # Minor. 18 | (?:#{DELIMITER}\d+) # Passive delimiter and patch. 19 | )\z # End of OR and string. 20 | /x 21 | end 22 | -------------------------------------------------------------------------------- /lib/versionaire/cast.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Versionaire 4 | # Refines Kernel in order to provide a top-level Version conversion function. 5 | module Cast 6 | refine Kernel do 7 | def Version(object) = Versionaire::Version object 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/versionaire/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Versionaire 4 | # The base error class for all gem related errors. 5 | class Error < StandardError 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/versionaire/extensions/option_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "optparse" 4 | require "versionaire" 5 | 6 | OptionParser.accept Versionaire::Version do |value| 7 | Versionaire::Version value 8 | rescue Versionaire::Error 9 | raise OptionParser::InvalidArgument, value 10 | end 11 | -------------------------------------------------------------------------------- /lib/versionaire/function.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "refinements/array" 4 | 5 | # The gem namespace. 6 | module Versionaire 7 | module_function 8 | 9 | # Conversion function (strict) for casting an object into a version. 10 | def Version object 11 | Converter.new(object).then do |converter| 12 | case object 13 | when String then converter.from_string 14 | when Array then converter.from_array 15 | when Hash then converter.from_hash 16 | when Version then object 17 | else converter.from_object 18 | end 19 | end 20 | end 21 | 22 | # Aids with converting objects into valid versions. 23 | class Converter 24 | using Refinements::Array 25 | 26 | def initialize object, model: Version 27 | @object = object 28 | @model = model 29 | end 30 | 31 | def from_string 32 | body = "Use: .., ., , or empty string." 33 | fail Error, error_message(object, body) unless PATTERN.match? object 34 | 35 | string_to_version 36 | end 37 | 38 | def from_array 39 | body = "Use: [, , ], [, ], [], or []." 40 | fail Error, error_message(object, body) unless (0..3).cover? object.size 41 | 42 | model.new(**attributes_for(object.pad(0, 3))) 43 | end 44 | 45 | def from_hash 46 | body = "Use: {major: , minor: , patch: }, " \ 47 | "{major: , minor: }, {major: }, or {}." 48 | fail Error, error_message(object, body) unless required_keys? 49 | 50 | Version[**object] 51 | end 52 | 53 | def from_object 54 | fail Error, error_message(object, "Use: String, Array, Hash, or Version.") 55 | end 56 | 57 | private 58 | 59 | attr_reader :object, :model 60 | 61 | def string_to_version 62 | object.split(DELIMITER) 63 | .map(&:to_i) 64 | .then { |numbers| numbers.pad 0, 3 } 65 | .then { |values| model.new(**attributes_for(values)) } 66 | end 67 | 68 | def attributes_for(values) = model.members.zip(values).to_h 69 | 70 | def required_keys? = object.keys.all? { |key| model.members.include? key } 71 | 72 | def error_message(object, body) = "Invalid version conversion: #{object}. #{body}" 73 | end 74 | 75 | private_constant :Converter 76 | end 77 | -------------------------------------------------------------------------------- /lib/versionaire/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "refinements/array" 4 | 5 | module Versionaire 6 | # An immutable, semantic version value object. 7 | Version = Data.define :major, :minor, :patch do 8 | include Comparable 9 | 10 | using Refinements::Array 11 | 12 | def initialize major: 0, minor: 0, patch: 0 13 | super 14 | validate 15 | end 16 | 17 | def +(other) = add other 18 | 19 | def -(other) = substract other 20 | 21 | def ==(other) = hash == other.hash 22 | 23 | alias_method :eql?, :== 24 | 25 | def <=>(other) = to_s <=> other.to_s 26 | 27 | def down(key, value = 1) = substract({key => value}) 28 | 29 | def up(key, value = 1) = add({key => value}) 30 | 31 | def bump key 32 | case key 33 | when :major then bump_major 34 | when :minor then bump_minor 35 | when :patch then bump_patch 36 | else fail Error, %(Invalid key: #{key.inspect}. Use: #{members.to_sentence "or"}.) 37 | end 38 | end 39 | 40 | def inspect = to_s.inspect 41 | 42 | def to_proc = method(:public_send).to_proc 43 | 44 | def to_s = to_a.join DELIMITER 45 | 46 | alias_method :to_str, :to_s 47 | 48 | alias_method :to_a, :deconstruct 49 | 50 | private 51 | 52 | def validate 53 | fail Error, "Major, minor, and patch must be a number." unless to_a.all? Integer 54 | fail Error, "Major, minor, and patch must be a positive number." if to_a.any?(&:negative?) 55 | end 56 | 57 | def add other 58 | attributes = other.to_h 59 | attributes.each { |key, value| attributes[key] = public_send(key) + value } 60 | with(**attributes) 61 | end 62 | 63 | def substract other 64 | attributes = other.to_h 65 | attributes.each { |key, value| attributes[key] = public_send(key) - value } 66 | with(**attributes) 67 | end 68 | 69 | def bump_major = with major: major + 1, minor: 0, patch: 0 70 | 71 | def bump_minor = with major:, minor: minor + 1, patch: 0 72 | 73 | def bump_patch = with major:, minor:, patch: patch + 1 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /spec/lib/versionaire/cast_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Versionaire::Cast do 6 | using described_class 7 | 8 | let(:version) { Versionaire::Version[major: 1, minor: 2, patch: 3] } 9 | 10 | describe "Kernel.Version" do 11 | context "with string" do 12 | it "converts major, minor, and patch" do 13 | expect(Version("1.2.3")).to eq(version) 14 | end 15 | 16 | it "converts major and minor" do 17 | expect(Version("1.2")).to eq(Versionaire::Version("1.2.0")) 18 | end 19 | 20 | it "converts major only" do 21 | expect(Version("1")).to eq(Versionaire::Version("1.0.0")) 22 | end 23 | 24 | it "converts empty string" do 25 | expect(Version("")).to eq(Versionaire::Version("0.0.0")) 26 | end 27 | 28 | it "fails with conversion error for invalid string" do 29 | result = proc { Version "bogus" } 30 | 31 | expect(&result).to raise_error( 32 | Versionaire::Error, 33 | "Invalid version conversion: bogus. " \ 34 | "Use: .., ., , or empty string." 35 | ) 36 | end 37 | end 38 | 39 | context "with array" do 40 | it "converts array with three arguments" do 41 | expect(Version([1, 2, 3])).to eq(version) 42 | end 43 | 44 | it "converts array with two arguments" do 45 | expect(Version([1, 2])).to eq(Versionaire::Version("1.2.0")) 46 | end 47 | 48 | it "converts array with one argument" do 49 | version = Versionaire::Version[major: 1] 50 | expect(Version([1])).to eq(version) 51 | end 52 | 53 | it "converts empty array" do 54 | expect(Version([])).to eq(Versionaire::Version.new) 55 | end 56 | 57 | it "fails with conversion error for array with more than three arguments" do 58 | result = proc { Version [1, 2, 3, 4] } 59 | 60 | expect(&result).to raise_error( 61 | Versionaire::Error, 62 | "Invalid version conversion: [1, 2, 3, 4]. " \ 63 | "Use: [, , ], [, ], [], or []." 64 | ) 65 | end 66 | end 67 | 68 | context "with hash" do 69 | it "converts major, minor, and patch" do 70 | expect(Version(major: 1, minor: 2, patch: 3)).to eq(version) 71 | end 72 | 73 | it "converts major and minor" do 74 | expect(Version(major: 1, minor: 2)).to eq(Versionaire::Version("1.2.0")) 75 | end 76 | 77 | it "converts major only" do 78 | expect(Version(major: 1)).to eq(Versionaire::Version("1.0.0")) 79 | end 80 | 81 | it "converts empty hash" do 82 | expect(Version({})).to eq(Versionaire::Version("0.0.0")) 83 | end 84 | 85 | it "fails with conversion error for invalid keys" do 86 | result = proc { Version bogus: "test" } 87 | 88 | expect(&result).to raise_error( 89 | Versionaire::Error, 90 | %(Invalid version conversion: {bogus: "test"}. ) \ 91 | "Use: {major: , minor: , patch: }, " \ 92 | "{major: , minor: }, {major: }, or {}." 93 | ) 94 | end 95 | end 96 | 97 | context "with version" do 98 | it "returns version" do 99 | expect(Version(version)).to eq(version) 100 | end 101 | end 102 | 103 | context "with unsupported primitive" do 104 | it "fails with conversion error" do 105 | result = proc { Version 1 } 106 | 107 | expect(&result).to raise_error( 108 | Versionaire::Error, 109 | "Invalid version conversion: 1. Use: String, Array, Hash, or Version." 110 | ) 111 | end 112 | end 113 | 114 | context "with unsupported object" do 115 | let(:object) { Object.new } 116 | 117 | it "fails with conversion error" do 118 | result = proc { Version object } 119 | 120 | expect(&result).to raise_error( 121 | Versionaire::Error, 122 | "Invalid version conversion: #{object}. " \ 123 | "Use: String, Array, Hash, or Version." 124 | ) 125 | end 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /spec/lib/versionaire/extensions/option_parser_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "versionaire/extensions/option_parser" 5 | 6 | RSpec.describe OptionParser do 7 | using Versionaire::Cast 8 | 9 | subject :parser do 10 | described_class.new do |instance| 11 | instance.on "--tag VERSION", Versionaire::Version, "Casts to version." do |value| 12 | options[:version] = value 13 | end 14 | end 15 | end 16 | 17 | let(:options) { Hash.new } 18 | 19 | it "casts input as version" do 20 | parser.parse! %w[--tag 1.2.3] 21 | expect(options).to eq(version: Version("1.2.3")) 22 | end 23 | 24 | it "fails when input doesn't resemble a version" do 25 | expectation = proc { parser.parse! %w[--tag unknown] } 26 | expect(&expectation).to raise_error(described_class::InvalidArgument, /unknown/) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/lib/versionaire/function_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe "Function" do 6 | let(:version) { Versionaire::Version[major: 1, minor: 2, patch: 3] } 7 | 8 | describe "Version" do 9 | context "with string" do 10 | it "converts major, minor, and patch" do 11 | expect(Versionaire.Version("1.2.3")).to eq(version) 12 | end 13 | 14 | it "converts major and minor" do 15 | expect(Versionaire.Version("1.2")).to eq(Versionaire::Version[major: 1, minor: 2, patch: 0]) 16 | end 17 | 18 | it "converts major only" do 19 | expect(Versionaire.Version("1")).to eq(Versionaire::Version[major: 1, minor: 0, patch: 0]) 20 | end 21 | 22 | it "converts empty string" do 23 | expect(Versionaire.Version("")).to eq(Versionaire::Version[major: 0, minor: 0, patch: 0]) 24 | end 25 | 26 | it "fails with conversion error for invalid string" do 27 | result = proc { Versionaire.Version "bogus" } 28 | 29 | expect(&result).to raise_error( 30 | Versionaire::Error, 31 | "Invalid version conversion: bogus. " \ 32 | "Use: .., ., , or empty string." 33 | ) 34 | end 35 | end 36 | 37 | context "with array" do 38 | it "converts array with three arguments" do 39 | expect(Versionaire.Version([1, 2, 3])).to eq(version) 40 | end 41 | 42 | it "converts array with two arguments" do 43 | expect(Versionaire.Version([1, 2])).to eq( 44 | Versionaire::Version[major: 1, minor: 2, patch: 0] 45 | ) 46 | end 47 | 48 | it "converts array with one argument" do 49 | version = Versionaire::Version[major: 1] 50 | expect(Versionaire.Version([1])).to eq(version) 51 | end 52 | 53 | it "converts empty array" do 54 | expect(Versionaire.Version([])).to eq(Versionaire::Version[major: 0, minor: 0, patch: 0]) 55 | end 56 | 57 | it "fails with conversion error for array with more than three arguments" do 58 | result = proc { Versionaire.Version [1, 2, 3, 4] } 59 | 60 | expect(&result).to raise_error( 61 | Versionaire::Error, 62 | "Invalid version conversion: [1, 2, 3, 4]. " \ 63 | "Use: [, , ], [, ], [], or []." 64 | ) 65 | end 66 | end 67 | 68 | context "with hash" do 69 | it "converts major, minor, and patch" do 70 | expect(Versionaire.Version(major: 1, minor: 2, patch: 3)).to eq(version) 71 | end 72 | 73 | it "converts major and minor" do 74 | expect(Versionaire.Version(major: 1, minor: 2)).to eq( 75 | Versionaire::Version[major: 1, minor: 2, patch: 0] 76 | ) 77 | end 78 | 79 | it "converts major only" do 80 | expect(Versionaire.Version(major: 1)).to eq( 81 | Versionaire::Version[major: 1, minor: 0, patch: 0] 82 | ) 83 | end 84 | 85 | it "converts empty hash" do 86 | expect(Versionaire.Version({})).to eq(Versionaire::Version[major: 0, minor: 0, patch: 0]) 87 | end 88 | 89 | it "fails with conversion error for invalid keys" do 90 | result = proc { Versionaire.Version bogus: "test" } 91 | 92 | expect(&result).to raise_error( 93 | Versionaire::Error, 94 | %(Invalid version conversion: {bogus: "test"}. ) \ 95 | "Use: {major: , minor: , patch: }, " \ 96 | "{major: , minor: }, {major: }, or {}." 97 | ) 98 | end 99 | end 100 | 101 | context "with version" do 102 | it "returns version" do 103 | expect(Versionaire.Version(version)).to eq(version) 104 | end 105 | end 106 | 107 | context "with unsupported primitive" do 108 | it "fails with conversion error" do 109 | result = proc { Versionaire.Version 1 } 110 | 111 | expect(&result).to raise_error( 112 | Versionaire::Error, 113 | "Invalid version conversion: 1. Use: String, Array, Hash, or Version." 114 | ) 115 | end 116 | end 117 | 118 | context "with unsupported object" do 119 | let(:object) { Object.new } 120 | 121 | it "fails with conversion error" do 122 | result = proc { Versionaire.Version object } 123 | 124 | expect(&result).to raise_error( 125 | Versionaire::Error, 126 | "Invalid version conversion: #{object}. Use: String, Array, Hash, or Version." 127 | ) 128 | end 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /spec/lib/versionaire/version_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Versionaire::Version do 6 | subject(:version) { described_class.new major: 1, minor: 2, patch: 3 } 7 | 8 | describe "#initialize" do 9 | context "with no arguments" do 10 | it "answers default version" do 11 | expect(described_class.new).to eq(described_class.new(major: 0, minor: 0, patch: 0)) 12 | end 13 | end 14 | 15 | context "with string value" do 16 | it "fails with invalid number error" do 17 | result = proc { described_class.new major: "1" } 18 | expect(&result).to raise_error(Versionaire::Error) 19 | end 20 | end 21 | 22 | context "with negative value" do 23 | it "fails with invalid number error" do 24 | result = proc { described_class.new major: -1 } 25 | expect(&result).to raise_error(Versionaire::Error) 26 | end 27 | end 28 | end 29 | 30 | describe "#+" do 31 | let(:other) { described_class.new major: 7, minor: 3, patch: 1 } 32 | 33 | it "adds versions" do 34 | result = version + other 35 | expect(result.to_s).to eq("8.5.4") 36 | end 37 | end 38 | 39 | describe "#-" do 40 | let(:other) { described_class.new major: 1, minor: 1, patch: 1 } 41 | 42 | it "answers new version when result remains positive" do 43 | result = version - other 44 | expect(result.to_s).to eq("0.1.2") 45 | end 46 | 47 | it "fails when result is negative" do 48 | other = described_class.new patch: 30 49 | result = proc { version - other } 50 | 51 | expect(&result).to raise_error(Versionaire::Error) 52 | end 53 | end 54 | 55 | describe "#==" do 56 | let(:similar) { described_class.new major: 1, minor: 2, patch: 3 } 57 | let(:different) { described_class.new major: 2 } 58 | 59 | context "with same values" do 60 | it "answers true" do 61 | expect(version).to eq(similar) 62 | end 63 | end 64 | 65 | context "with different values" do 66 | it "answers false" do 67 | expect(version).not_to eq(different) 68 | end 69 | end 70 | 71 | context "with different type (string)" do 72 | it "answers false" do 73 | expect(version).not_to eq("1.2.3") 74 | end 75 | end 76 | 77 | context "with different type (array)" do 78 | it "answers false" do 79 | expect(version).not_to eq([1, 2, 3]) 80 | end 81 | end 82 | 83 | context "with different type (hash)" do 84 | it "answers false" do 85 | expect(version).not_to eq({major: 1, minor: 2, patch: 3}) 86 | end 87 | end 88 | end 89 | 90 | describe "#eql?" do 91 | let(:similar) { described_class.new major: 1, minor: 2, patch: 3 } 92 | let(:different) { described_class.new major: 2 } 93 | 94 | context "with same values" do 95 | it "answers true" do 96 | expect(version).to eql(similar) 97 | end 98 | end 99 | 100 | context "with different values" do 101 | it "answers false" do 102 | expect(version).not_to eql(different) 103 | end 104 | end 105 | 106 | context "with different type" do 107 | it "answers false" do 108 | expect(version).not_to eql("1.2.3") 109 | end 110 | end 111 | end 112 | 113 | describe "#equal?" do 114 | let(:similar) { described_class.new major: 1, minor: 2, patch: 3 } 115 | let(:different) { described_class.new major: 2 } 116 | 117 | context "with same instances" do 118 | it "answers true" do 119 | expect(version).to equal(version) 120 | end 121 | end 122 | 123 | context "with same values" do 124 | it "answers false" do 125 | expect(version).not_to equal(similar) 126 | end 127 | end 128 | 129 | context "with different values" do 130 | it "answers false" do 131 | expect(version).not_to equal(different) 132 | end 133 | end 134 | 135 | context "with different type" do 136 | it "answers false" do 137 | expect(version).not_to equal("1.2.3") 138 | end 139 | end 140 | end 141 | 142 | describe "#<=>" do 143 | let(:similar) { described_class.new major: 1 } 144 | 145 | context "when greater than" do 146 | subject(:version) { described_class.new major: 2 } 147 | 148 | it "answers 1" do 149 | result = version <=> similar 150 | expect(result).to eq(1) 151 | end 152 | end 153 | 154 | context "when equal to" do 155 | subject(:version) { described_class.new major: 1 } 156 | 157 | it "answers 0" do 158 | result = version <=> similar 159 | expect(result).to eq(0) 160 | end 161 | end 162 | 163 | context "when less than" do 164 | subject(:version) { described_class.new } 165 | 166 | it "answers -1" do 167 | result = version <=> similar 168 | expect(result).to eq(-1) 169 | end 170 | end 171 | end 172 | 173 | describe "#<" do 174 | let(:one) { Versionaire::Version "1.0.0" } 175 | let(:two) { Versionaire::Version "2.0.0" } 176 | 177 | it "answers true when less than" do 178 | expect(one < two).to be(true) 179 | end 180 | 181 | it "answers false when not less than" do 182 | expect(two < one).to be(false) 183 | end 184 | 185 | it "answers false when equal" do 186 | expect(one < Versionaire::Version("1.0.0")).to be(false) 187 | end 188 | end 189 | 190 | describe "#<=" do 191 | let(:one) { Versionaire::Version "1.0.0" } 192 | let(:two) { Versionaire::Version "2.0.0" } 193 | 194 | it "answers true when less than" do 195 | expect(one <= two).to be(true) 196 | end 197 | 198 | it "answers false when not less than" do 199 | expect(two <= one).to be(false) 200 | end 201 | 202 | it "answers true when equal" do 203 | expect(one <= Versionaire::Version("1.0.0")).to be(true) 204 | end 205 | end 206 | 207 | describe "#>" do 208 | let(:one) { Versionaire::Version "1.0.0" } 209 | let(:two) { Versionaire::Version "2.0.0" } 210 | 211 | it "answers true when greater than" do 212 | expect(two > one).to be(true) 213 | end 214 | 215 | it "answers false when not greater than" do 216 | expect(one > two).to be(false) 217 | end 218 | 219 | it "answers false when equal" do 220 | expect(one > Versionaire::Version("1.0.0")).to be(false) 221 | end 222 | end 223 | 224 | describe "#>=" do 225 | let(:one) { Versionaire::Version "1.0.0" } 226 | let(:two) { Versionaire::Version "2.0.0" } 227 | 228 | it "answers true when greater than" do 229 | expect(two >= one).to be(true) 230 | end 231 | 232 | it "answers false when not greater than" do 233 | expect(one >= two).to be(false) 234 | end 235 | 236 | it "answers true when equal" do 237 | expect(one >= Versionaire::Version("1.0.0")).to be(true) 238 | end 239 | end 240 | 241 | describe "#between?" do 242 | let(:one) { Versionaire::Version "1.0.0" } 243 | let(:two) { Versionaire::Version "2.0.0" } 244 | let(:three) { Versionaire::Version "3.0.0" } 245 | 246 | it "answers true when between" do 247 | expect(two.between?(one, two)).to be(true) 248 | end 249 | 250 | it "answers false when not between" do 251 | expect(one.between?(two, three)).to be(false) 252 | end 253 | end 254 | 255 | describe "#clamp" do 256 | let(:one) { Versionaire::Version "1.0.0" } 257 | let(:two) { Versionaire::Version "2.0.0" } 258 | let(:three) { Versionaire::Version "3.0.0" } 259 | 260 | it "answers minimum when less than" do 261 | expect(one.clamp(two, three)).to eq(two) 262 | end 263 | 264 | it "answers maximum when greater than" do 265 | expect(three.clamp(one, two)).to eq(two) 266 | end 267 | 268 | it "answers equal when equal" do 269 | expect(one.clamp(one, one)).to eq(one) 270 | end 271 | end 272 | 273 | describe "#hash" do 274 | let(:similar) { described_class.new major: 1, minor: 2, patch: 3 } 275 | let(:different) { described_class.new major: 2 } 276 | 277 | context "with same values" do 278 | it "is identical" do 279 | expect(version.hash).to eq(similar.hash) 280 | end 281 | end 282 | 283 | context "with different values" do 284 | it "is different" do 285 | expect(version.hash).not_to eq(different.hash) 286 | end 287 | end 288 | 289 | context "with different type" do 290 | it "is different" do 291 | expect(version.hash).not_to eq("1.2.3".hash) 292 | end 293 | end 294 | end 295 | 296 | describe "inspect" do 297 | it "answer escaped version string" do 298 | expect(version.inspect).to eq(%("1.2.3")) 299 | end 300 | end 301 | 302 | describe "#down" do 303 | it "answers previous sequential major version" do 304 | expect(version.down(:major)).to eq(described_class[major: 0, minor: 2, patch: 3]) 305 | end 306 | 307 | it "answers previous sequential minor version" do 308 | expect(version.down(:minor)).to eq(described_class[major: 1, minor: 1, patch: 3]) 309 | end 310 | 311 | it "answers previous sequential patch version" do 312 | expect(version.down(:patch)).to eq(described_class[major: 1, minor: 2, patch: 2]) 313 | end 314 | 315 | it "answers previous version for given value" do 316 | expect(version.down(:minor, 2)).to eq(described_class[major: 1, minor: 0, patch: 3]) 317 | end 318 | 319 | it "fails when decreased to a negative version" do 320 | expectation = proc { version.down :major, 2 } 321 | expect(&expectation).to raise_error(Versionaire::Error, /must be.+positive/) 322 | end 323 | end 324 | 325 | describe "#up" do 326 | it "answers next sequential major version" do 327 | expect(version.up(:major)).to eq(described_class[major: 2, minor: 2, patch: 3]) 328 | end 329 | 330 | it "answers next sequential minor version" do 331 | expect(version.up(:minor)).to eq(described_class[major: 1, minor: 3, patch: 3]) 332 | end 333 | 334 | it "answers next sequential patch version" do 335 | expect(version.up(:patch)).to eq(described_class[major: 1, minor: 2, patch: 4]) 336 | end 337 | 338 | it "answers next version for given value" do 339 | expect(version.up(:major, 10)).to eq(described_class[major: 11, minor: 2, patch: 3]) 340 | end 341 | end 342 | 343 | describe "#bump" do 344 | it "answers next major version" do 345 | expect(version.bump(:major)).to eq(described_class[major: 2]) 346 | end 347 | 348 | it "answers next minor version" do 349 | expect(version.bump(:minor)).to eq(described_class[major: 1, minor: 3, patch: 0]) 350 | end 351 | 352 | it "answers next patch version" do 353 | expect(version.bump(:patch)).to eq(described_class[major: 1, minor: 2, patch: 4]) 354 | end 355 | 356 | it "fails with invalid key" do 357 | expectation = proc { version.bump :bogus } 358 | 359 | expect(&expectation).to raise_error( 360 | Versionaire::Error, "Invalid key: :bogus. Use: major, minor, or patch." 361 | ) 362 | end 363 | end 364 | 365 | describe "#to_a" do 366 | it "answers array" do 367 | expect(version.to_a).to contain_exactly(1, 2, 3) 368 | end 369 | end 370 | 371 | describe "#to_h" do 372 | it "answers hash" do 373 | expect(version.to_h).to eq(major: 1, minor: 2, patch: 3) 374 | end 375 | end 376 | 377 | describe "#to_proc" do 378 | it "answers a proc" do 379 | expect(version.to_proc).to be_a(Proc) 380 | end 381 | 382 | it "answers valid attribute" do 383 | expect(version.to_proc.call(:patch)).to eq(3) 384 | end 385 | 386 | it "answers values when mapped" do 387 | versions = [version, version] 388 | expect(versions.map(&:minor)).to contain_exactly(2, 2) 389 | end 390 | 391 | it "fails with invalid attribute" do 392 | expectation = proc { version.to_proc.call :bogus } 393 | expect(&expectation).to raise_error(NameError, /bogus/) 394 | end 395 | end 396 | 397 | describe "#to_s" do 398 | it "answers string" do 399 | expect(version.to_s).to eq("1.2.3") 400 | end 401 | end 402 | 403 | describe "#to_str" do 404 | it "answers string" do 405 | expect(version.to_str).to eq("1.2.3") 406 | end 407 | end 408 | end 409 | -------------------------------------------------------------------------------- /spec/lib/versionaire/versionaire_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Versionaire do 6 | describe "PATTERN" do 7 | it "matches empty string" do 8 | expect(described_class::PATTERN).to match("") 9 | end 10 | 11 | it "matches major only format" do 12 | expect(described_class::PATTERN).to match("1") 13 | end 14 | 15 | it "matches major and minor only format" do 16 | expect(described_class::PATTERN).to match("1.2") 17 | end 18 | 19 | it "matches major, minor, and patch format" do 20 | expect(described_class::PATTERN).to match("1.2.3") 21 | end 22 | 23 | it "matches multiple digit major, minor, and patch format" do 24 | expect(described_class::PATTERN).to match("11.222.3333") 25 | end 26 | 27 | it "matches similar version" do 28 | proof = described_class::Version.new major: 1, minor: 2, patch: 3 29 | expect(described_class::PATTERN).to match(proof) 30 | end 31 | 32 | it "does not match trailing major delimiter" do 33 | expect(described_class::PATTERN).not_to eq("1.") 34 | end 35 | 36 | it "does not match trailing minor delimiter" do 37 | expect(described_class::PATTERN).not_to eq("1.2.") 38 | end 39 | 40 | it "does not match trailing patch delimiter" do 41 | expect(described_class::PATTERN).not_to match("1.2.3.") 42 | end 43 | 44 | it "does not match numbers beyond patch" do 45 | expect(described_class::PATTERN).not_to match("1.2.3.4") 46 | end 47 | 48 | it "does not match letters" do 49 | expect(described_class::PATTERN).not_to match("a.b.c") 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "simplecov" 4 | 5 | unless ENV["NO_COVERAGE"] 6 | SimpleCov.start do 7 | add_filter %r(^/spec/) 8 | enable_coverage :branch 9 | enable_coverage_for_eval 10 | minimum_coverage_by_file line: 95, branch: 95 11 | end 12 | end 13 | 14 | Bundler.require :tools 15 | 16 | require "versionaire" 17 | 18 | SPEC_ROOT = Pathname(__dir__).realpath.freeze 19 | 20 | RSpec.configure do |config| 21 | config.color = true 22 | config.disable_monkey_patching! 23 | config.example_status_persistence_file_path = "./tmp/rspec-examples.txt" 24 | config.filter_run_when_matching :focus 25 | config.formatter = ENV.fetch("CI", false) == "true" ? :progress : :documentation 26 | config.order = :random 27 | config.pending_failure_output = :no_backtrace 28 | config.shared_context_metadata_behavior = :apply_to_host_groups 29 | config.warnings = true 30 | 31 | config.expect_with :rspec do |expectations| 32 | expectations.syntax = :expect 33 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 34 | end 35 | 36 | config.mock_with :rspec do |mocks| 37 | mocks.verify_doubled_constant_names = true 38 | mocks.verify_partial_doubles = true 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /versionaire.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "versionaire" 5 | spec.version = "14.2.0" 6 | spec.authors = ["Brooke Kuhlmann"] 7 | spec.email = ["brooke@alchemists.io"] 8 | spec.homepage = "https://alchemists.io/projects/versionaire" 9 | spec.summary = "An immutable, thread-safe, and strict semantic version type." 10 | spec.license = "Hippocratic-2.1" 11 | 12 | spec.metadata = { 13 | "bug_tracker_uri" => "https://github.com/bkuhlmann/versionaire/issues", 14 | "changelog_uri" => "https://alchemists.io/projects/versionaire/versions", 15 | "homepage_uri" => "https://alchemists.io/projects/versionaire", 16 | "funding_uri" => "https://github.com/sponsors/bkuhlmann", 17 | "label" => "Versionaire", 18 | "rubygems_mfa_required" => "true", 19 | "source_code_uri" => "https://github.com/bkuhlmann/versionaire" 20 | } 21 | 22 | spec.signing_key = Gem.default_key_path 23 | spec.cert_chain = [Gem.default_cert_path] 24 | 25 | spec.required_ruby_version = "~> 3.4" 26 | spec.add_dependency "refinements", "~> 13.0" 27 | 28 | spec.files = Dir["*.gemspec", "lib/**/*"] 29 | spec.extra_rdoc_files = Dir["README*", "LICENSE*"] 30 | end 31 | --------------------------------------------------------------------------------