├── .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 ├── demo ├── rake ├── rspec ├── rubocop └── setup ├── lib ├── runcom.rb └── runcom │ ├── cache.rb │ ├── config.rb │ ├── context.rb │ ├── data.rb │ ├── paths │ ├── common.rb │ └── home.rb │ └── state.rb ├── runcom.gemspec └── spec ├── lib ├── runcom │ ├── cache_spec.rb │ ├── config_spec.rb │ ├── context_spec.rb │ ├── data_spec.rb │ ├── paths │ │ ├── common_spec.rb │ │ └── home_spec.rb │ └── state_spec.rb └── runcom_spec.rb ├── spec_helper.rb └── support ├── fixtures └── invalid.yml └── shared_contexts └── temp_dir.rb /.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 "runcom.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 "runcom.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 | RSpec/MultipleMemoizedHelpers: 5 | Enabled: false 6 | -------------------------------------------------------------------------------- /.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 | IrresponsibleModule: 5 | enabled: false 6 | ModuleInitialize: 7 | exclude: 8 | - "Runcom" 9 | -------------------------------------------------------------------------------- /.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: Runcom 4 | abstract: A XDG enhanced run command manager for command line interfaces. 5 | version: 12.2.1 6 | license: Hippocratic-2.1 7 | date-released: 2025-05-21 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 | - command line interface 16 | - XDG 17 | - base directory specification 18 | - cache 19 | - configuration 20 | - data 21 | - runtime 22 | repository-code: https://github.com/bkuhlmann/runcom 23 | repository-artifact: https://rubygems.org/gems/runcom 24 | url: https://alchemists.io/projects/runcom 25 | -------------------------------------------------------------------------------- /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 | :xdg_link: link:https://alchemists.io/projects/xdg[XDG] 6 | :etcher_link: link:https://alchemists.io/projects/etcher[Etcher] 7 | 8 | = Runcom 9 | 10 | Runcom is a link:https://en.wikipedia.org/wiki/Run_commands[Run Command] portmanteau (i.e. `run + [com]mand = runcom`) which provides common functionality for Command Line Interfaces (CLIs) in which to manage global/local caches, configurations, data, and/or state. This is done by leveraging the https://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html[XDG Base Directory Specification] built atop the {xdg_link} implementation. In other words, Runcom is an enhanced version of {xdg_link} which specializes in dynamic global and local detection. 11 | 12 | toc::[] 13 | 14 | == Features 15 | 16 | * Wraps the {xdg_link} implementation which provides access to the following environment variables: 17 | ** `+$XDG_CACHE_HOME+` 18 | ** `+$XDG_CONFIG_HOME+` 19 | ** `+$XDG_CONFIG_DIRS+` 20 | ** `+$XDG_DATA_HOME+` 21 | ** `+$XDG_DATA_DIRS+` 22 | ** `+$XDG_STATE_HOME+` 23 | * Enhances the {xdg_link} cache, config, data, and state implementations with dynamic global and local detection. 24 | 25 | == Requirements 26 | 27 | . https://www.ruby-lang.org[Ruby] 28 | 29 | == Setup 30 | 31 | To install _with_ security, run: 32 | 33 | [source,bash] 34 | ---- 35 | # 💡 Skip this line if you already have the public certificate installed. 36 | gem cert --add <(curl --compressed --location https://alchemists.io/gems.pem) 37 | gem install runcom --trust-policy HighSecurity 38 | ---- 39 | 40 | To install _without_ security, run: 41 | 42 | [source,bash] 43 | ---- 44 | gem install runcom 45 | ---- 46 | 47 | You can also add the gem directly to your project: 48 | 49 | [source,bash] 50 | ---- 51 | bundle add runcom 52 | ---- 53 | 54 | Once the gem is installed, you only need to require it: 55 | 56 | [source,ruby] 57 | ---- 58 | require "runcom" 59 | ---- 60 | 61 | == Usage 62 | 63 | The following describes the enhancements built atop the {xdg_link} implementation. 64 | 65 | === Overview 66 | 67 | While there isn’t a sole convenience object as found with the `XDG` gem, you can instantiate each object individually: 68 | 69 | [source,ruby] 70 | ---- 71 | cache = Runcom::Cache.new "demo/data.json" 72 | config = Runcom::Config.new "demo/configuration.yml" 73 | data = Runcom::Data.new "demo/store.dat" 74 | state = Runcom::State.new "demo/history.log" 75 | ---- 76 | 77 | By default, each Runcom object expects a relative file path but you can also use a fully qualified path when constructing a new instance. 78 | 79 | Each of the above objects share the same Object API: 80 | 81 | * `#initial`: Answers the initial path -- which can be a relative or absolute path -- from which the object was constructed. 82 | * `#namespace`: Answers the namespace as a pathname object from which the instance was constructed. The namespace must be unique and identical across the cache, config, data, and state objects since this is what identifies and organizes all files associated with your program. 83 | * `#file_name`: Answers the file name from which the object was constructed. 84 | * `#active`: Answers first _existing file path_ as computed by `+$XDG_*_HOME+` followed by each computed `+$XDG_*_DIRS+` path in order defined. Otherwise, `nil` is answered back when no path exists. 85 | * `#passive`: Answers first path as computed by `+$XDG_*_HOME+` followed by each computed `+$XDG_*_DIRS+` path in order defined which _may_ or _may not_ exist. This behaves like `#active` but doesn't care if the path exists. Handy for situations where you'd like the active path but can fallback to creating the global path if otherwise. 86 | * `#global`: Answers the first _existing_ or _non-existing_ global path. 87 | * `#local`: Answers the first _existing_ or _non-existing_ local path. 88 | * `#all`: Answers all paths which is the combined `+$XDG_*_HOME+` and `+$XDG_*_DIRS+` values in order defined. These paths _may_ or _may not_ exist. 89 | * `#to_s`: Answers an _explicit_ string cast for the current environment. 90 | * `#to_str`: Answers an _implicit_ string cast for the current environment. 91 | * `#inspect`: Answers object inspection complete with object type, object ID, and all environment variables. 92 | 93 | === Examples 94 | 95 | The following are examples of what you will see when exploring the Runcom objects within an IRB console: 96 | 97 | [source,ruby] 98 | ---- 99 | # Initialization 100 | 101 | cache = XDG::Cache.new "demo/projects.json" 102 | config = XDG::Config.new "demo/settings.yml" 103 | data = XDG::Data.new "demo/vault.store" 104 | state = XDG::State.new "demo/history.log" 105 | 106 | # Paths 107 | 108 | cache.initial # "#" 109 | cache.namespace # "#" 110 | cache.file_name # "#" 111 | cache.active # nil 112 | cache.passive # "#" 113 | cache.global # "#" 114 | cache.local # "#" 115 | cache.all # ["#", "#"] 116 | 117 | config.initial # "#" 118 | config.namespace # "#" 119 | config.file_name # "#" 120 | config.active # nil 121 | config.passive # "#" 122 | config.global # "#" 123 | config.local # "#" 124 | config.all # ["#", "#", "#"] 125 | 126 | data.initial # "#" 127 | data.namespace # "#" 128 | data.file_name # "#" 129 | data.active # nil 130 | data.passive # "#" 131 | data.global # "#" 132 | data.local # "#" 133 | data.all # ["#", "#", "#", "#"] 134 | 135 | state.initial # "#" 136 | state.namespace # "#" 137 | state.file_name # "#" 138 | state.active # nil 139 | state.passive # "#" 140 | state.global # "#" 141 | state.local # "#" 142 | state.all # ["#", "#"] 143 | 144 | # Casts (explicit and implicit) 145 | 146 | cache.to_s # "XDG_CACHE_HOME=/Users/demo/Engineering/OSS/runcom/.cache:/Users/demo/.cache" 147 | config.to_s # "XDG_CONFIG_HOME=/Users/demo/Engineering/OSS/runcom/.config:/Users/demo/.config XDG_CONFIG_DIRS=/etc/xdg" 148 | data.to_s # "XDG_DATA_HOME=/Users/demo/Engineering/OSS/runcom/.local/share:/Users/demo/.local/share XDG_DATA_DIRS=/usr/local/share:/usr/share" 149 | state.to_s # "XDG_STATE_HOME=/Users/demo/Engineering/OSS/runcom/.local/state:/Users/demo/.local/state" 150 | 151 | cache.to_str # "XDG_CACHE_HOME=/Users/demo/Engineering/OSS/runcom/.cache:/Users/demo/.cache" 152 | config.to_str # "XDG_CONFIG_HOME=/Users/demo/Engineering/OSS/runcom/.config:/Users/demo/.config XDG_CONFIG_DIRS=/etc/xdg" 153 | data.to_str # "XDG_DATA_HOME=/Users/demo/Engineering/OSS/runcom/.local/share:/Users/demo/.local/share XDG_DATA_DIRS=/usr/local/share:/usr/share" 154 | state.to_str # "XDG_STATE_HOME=/Users/demo/Engineering/OSS/runcom/.local/state:/Users/demo/.local/state" 155 | 156 | # Inspection 157 | 158 | cache.inspect # "#" 159 | config.inspect # "#" 160 | data.inspect # "#" 161 | state.inspect # "#" 162 | ---- 163 | 164 | === Variable Priority 165 | 166 | Path precedence is determined in the following order (with the first taking highest priority): 167 | 168 | . *Local Configuration*: If a `+$XDG_*_HOME+` or `+$XDG_*_DIRS+` path relative to the 169 | current working directory is detected, it will take precedence over the global configuration. 170 | This is the same behavior as found in Git where the local `.git/config` takes precedence over the 171 | global `$HOME/.gitconfig`. 172 | . *Global Configuration*: When a local configuration isn’t found, the global configuration is used 173 | as defined by the _XDG Base Directory Specification_. 174 | 175 | === Building Blocks 176 | 177 | While {xdg_link} and Runcom are powerful in their own right, a great building block you can add on top of this gem is the {etcher_link} gem which loads, transforms, validates, and produces structured data from raw Runcom information. For more sophisticated applications, this synergetic coupling of `XDG + Runcom + Etcher` makes for nicely designed architectures. 178 | 179 | === Examples 180 | 181 | Examples of gems built atop this gem are: 182 | 183 | * link:https://alchemists.io/projects/rubysmith[Rubysmith]: A command line interface for 184 | smithing Ruby projects. 185 | * link:https://alchemists.io/projects/gemsmith[Gemsmith]: A command line interface for smithing 186 | new Ruby gems. 187 | * link:https://alchemists.io/projects/hanamismith[Hanamismith]: A command line interface for smithing link:https://hanamirb.org[Hanami] projects. 188 | * link:https://alchemists.io/projects/git-lint[Git Lint]: Enforces consistent Git commits. 189 | * link:https://alchemists.io/projects/milestoner[Milestoner]: A command line interface for 190 | releasing Git repository milestones. 191 | * link:https://alchemists.io/projects/pennyworth[Pennyworth]: A command line interface that 192 | enhances and extends link:https://www.alfredapp.com[Alfred] with Ruby support. 193 | * link:https://alchemists.io/projects/pragmater[Pragmater]: A command line interface for 194 | managing/formatting source file pragma comments. 195 | * link:https://alchemists.io/projects/sublime_text_kit[Sublime Text Kit]: A command line 196 | interface for managing Sublime Text metadata. 197 | * link:https://alchemists.io/projects/tocer[Tocer]: A command line interface for generating 198 | Markdown table of contents. 199 | 200 | == Development 201 | 202 | To contribute, run: 203 | 204 | [source,bash] 205 | ---- 206 | git clone https://github.com/bkuhlmann/runcom 207 | cd runcom 208 | bin/setup 209 | ---- 210 | 211 | You can also use the IRB console for direct access to all objects: 212 | 213 | [source,bash] 214 | ---- 215 | bin/console 216 | ---- 217 | 218 | Lastly, there is a `bin/demo` script which displays default functionality for quick visual reference. This is the same script used to generate the usage examples shown at the top of this document. 219 | 220 | [source,bash] 221 | ---- 222 | bin/demo 223 | ---- 224 | 225 | == Tests 226 | 227 | To test, run: 228 | 229 | [source,bash] 230 | ---- 231 | bin/rake 232 | ---- 233 | 234 | == link:https://alchemists.io/policies/license[License] 235 | 236 | == link:https://alchemists.io/policies/security[Security] 237 | 238 | == link:https://alchemists.io/policies/code_of_conduct[Code of Conduct] 239 | 240 | == link:https://alchemists.io/policies/contributions[Contributions] 241 | 242 | == link:https://alchemists.io/policies/developer_certificate_of_origin[Developer Certificate of Origin] 243 | 244 | == link:https://alchemists.io/projects/runcom/versions[Versions] 245 | 246 | == link:https://alchemists.io/community[Community] 247 | 248 | == Credits 249 | 250 | * Built with link:https://alchemists.io/projects/gemsmith[Gemsmith]. 251 | * Engineered by link:https://alchemists.io/team/brooke_kuhlmann[Brooke Kuhlmann]. 252 | -------------------------------------------------------------------------------- /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 "runcom" 9 | 10 | IRB.start __FILE__ 11 | -------------------------------------------------------------------------------- /bin/demo: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | Bundler.require :tools 6 | 7 | require "runcom" 8 | 9 | home = Dir.home 10 | demo = "/Users/demo" 11 | 12 | cache = Runcom::Cache.new "demo/projects.json" 13 | config = Runcom::Config.new "demo/settings.yml" 14 | data = Runcom::Data.new "demo/vault.store" 15 | state = Runcom::State.new "demo/history.log" 16 | 17 | puts "# Initialization\n\n" 18 | 19 | puts %(cache = XDG::Cache.new "demo/projects.json") 20 | puts %(config = XDG::Config.new "demo/settings.yml") 21 | puts %(data = XDG::Data.new "demo/vault.store") 22 | puts %(state = XDG::State.new "demo/history.log") 23 | 24 | puts "\n# Paths\n\n" 25 | 26 | puts %(cache.initial # "#{cache.initial.sub(home, demo).inspect}") 27 | puts %(cache.namespace # "#{cache.namespace.inspect}") 28 | puts %(cache.file_name # "#{cache.file_name.inspect}") 29 | puts %(cache.active # #{cache.active.inspect}) 30 | puts %(cache.passive # "#{cache.passive.sub(home, demo).inspect}") 31 | puts %(cache.global # "#{cache.global.sub(home, demo).inspect}") 32 | puts %(cache.local # "#{cache.local.sub(home, demo).inspect}") 33 | puts %(cache.all # #{cache.all.map { |path| path.sub(home, demo).inspect }}) 34 | puts 35 | puts %(config.initial # "#{config.initial.sub(home, demo).inspect}") 36 | puts %(config.namespace # "#{config.namespace.inspect}") 37 | puts %(config.file_name # "#{config.file_name.inspect}") 38 | puts %(config.active # #{config.active.inspect}) 39 | puts %(config.passive # "#{config.passive.sub(home, demo).inspect}") 40 | puts %(config.global # "#{config.global.sub(home, demo).inspect}") 41 | puts %(config.local # "#{config.local.sub(home, demo).inspect}") 42 | puts %(config.all # #{config.all.map { |path| path.sub(home, demo).inspect }}) 43 | puts 44 | puts %(data.initial # "#{data.initial.sub(home, demo).inspect}") 45 | puts %(data.namespace # "#{data.namespace.inspect}") 46 | puts %(data.file_name # "#{data.file_name.inspect}") 47 | puts %(data.active # #{data.active.inspect}) 48 | puts %(data.passive # "#{data.passive.sub(home, demo).inspect}") 49 | puts %(data.global # "#{data.global.sub(home, demo).inspect}") 50 | puts %(data.local # "#{data.local.sub(home, demo).inspect}") 51 | puts %(data.all # #{data.all.map { |path| path.sub(home, demo).inspect }}) 52 | puts 53 | puts %(state.initial # "#{state.initial.sub(home, demo).inspect}") 54 | puts %(state.namespace # "#{state.namespace.inspect}") 55 | puts %(state.file_name # "#{state.file_name.inspect}") 56 | puts %(state.active # #{state.active.inspect}) 57 | puts %(state.passive # "#{state.passive.sub(home, demo).inspect}") 58 | puts %(state.global # "#{state.global.sub(home, demo).inspect}") 59 | puts %(state.local # "#{state.local.sub(home, demo).inspect}") 60 | puts %(state.all # #{state.all.map { |path| path.sub(home, demo).inspect }}) 61 | 62 | puts "\n# Casts (explicit and implicit)\n\n" 63 | 64 | puts %(cache.to_s # "#{cache.to_s.gsub home, demo}") 65 | puts %(config.to_s # "#{config.to_s.gsub home, demo}") 66 | puts %(data.to_s # "#{data.to_s.gsub home, demo}") 67 | puts %(state.to_s # "#{state.to_s.gsub home, demo}") 68 | puts 69 | puts %(cache.to_str # "#{cache.to_str.gsub home, demo}") 70 | puts %(config.to_str # "#{config.to_str.gsub home, demo}") 71 | puts %(data.to_str # "#{data.to_str.gsub home, demo}") 72 | puts %(state.to_str # "#{state.to_str.gsub home, demo}") 73 | 74 | puts "\n# Inspection\n\n" 75 | 76 | puts %(cache.inspect # "#{cache.inspect.gsub home, demo}") 77 | puts %(config.inspect # "#{config.inspect.gsub home, demo}") 78 | puts %(data.inspect # "#{data.inspect.gsub home, demo}") 79 | puts %(state.inspect # "#{state.inspect.gsub home, demo}") 80 | -------------------------------------------------------------------------------- /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/runcom.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "xdg" 4 | require "zeitwerk" 5 | 6 | Zeitwerk::Loader.new.then do |loader| 7 | loader.tag = File.basename __FILE__, ".rb" 8 | loader.push_dir __dir__ 9 | loader.setup 10 | end 11 | 12 | # Main namespace. 13 | module Runcom 14 | def self.loader registry = Zeitwerk::Registry 15 | @loader ||= registry.loaders.each.find { |loader| loader.tag == File.basename(__FILE__, ".rb") } 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/runcom/cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | 5 | module Runcom 6 | # A developer friendly wrapper of XDG cache. 7 | class Cache 8 | extend Forwardable 9 | 10 | CONTEXT = Context.new xdg: XDG::Cache 11 | 12 | delegate %i[initial namespace file_name active passive global local all to_s to_str] => :common 13 | 14 | def initialize path, context: CONTEXT 15 | @common = Paths::Common.new(path, context:) 16 | freeze 17 | end 18 | 19 | def inspect = "#<#{self.class}:#{object_id} #{common}>" 20 | 21 | private 22 | 23 | attr_reader :common 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/runcom/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | 5 | module Runcom 6 | # A developer friendly wrapper of XDG config. 7 | class Config 8 | extend Forwardable 9 | 10 | CONTEXT = Context.new xdg: XDG::Config 11 | 12 | delegate %i[initial namespace file_name active passive global local all to_s to_str] => :common 13 | 14 | def initialize path, context: CONTEXT 15 | @common = Paths::Common.new(path, context:) 16 | freeze 17 | end 18 | 19 | def inspect = "#<#{self.class}:#{object_id} #{common}>" 20 | 21 | private 22 | 23 | attr_reader :common 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/runcom/context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Runcom 4 | # A common context for all XDG custom objects. 5 | Context = ::Data.define :home, :environment, :xdg do 6 | def initialize home: Paths::Home, environment: ENV, xdg: nil 7 | computed_xdg = xdg.is_a?(Class) ? xdg.new(home:, environment:) : xdg 8 | super home:, environment:, xdg: computed_xdg 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/runcom/data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | 5 | module Runcom 6 | # A developer friendly wrapper of XDG data. 7 | class Data 8 | extend Forwardable 9 | 10 | CONTEXT = Context.new xdg: XDG::Data 11 | 12 | delegate %i[initial namespace file_name active passive global local all to_s to_str] => :common 13 | 14 | def initialize path, context: CONTEXT 15 | @common = Paths::Common.new(path, context:) 16 | freeze 17 | end 18 | 19 | def inspect = "#<#{self.class}:#{object_id} #{common}>" 20 | 21 | private 22 | 23 | attr_reader :common 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/runcom/paths/common.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "refinements/pathname" 4 | 5 | module Runcom 6 | module Paths 7 | # Provides common path/functionality for all XDG enhanced objects. 8 | class Common 9 | using Refinements::Pathname 10 | 11 | attr_reader :initial 12 | 13 | def initialize initial, context: Context.new 14 | @initial = Pathname initial 15 | @context = context 16 | freeze 17 | end 18 | 19 | def namespace = initial.parent 20 | 21 | def file_name = initial.basename 22 | 23 | def active = all.select(&:file?).find(&:exist?) 24 | 25 | def passive = active || global 26 | 27 | def global 28 | all.tap { |paths| paths.delete local } 29 | .first 30 | end 31 | 32 | def local = all.first 33 | 34 | def all = xdg.all.map { |root| root.join initial } 35 | 36 | def to_s = xdg.to_s 37 | 38 | alias to_str to_s 39 | 40 | def inspect = "#<#{self.class}:#{object_id} #{xdg}>" 41 | 42 | private 43 | 44 | attr_reader :context 45 | 46 | def xdg = context.xdg 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/runcom/paths/home.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | require "pathname" 5 | 6 | module Runcom 7 | module Paths 8 | # A XDG home path that prefers local over global path. 9 | class Home 10 | extend Forwardable 11 | 12 | delegate %i[key value default] => :standard 13 | 14 | def initialize pair, environment = ENV 15 | @standard = XDG::Paths::Home.new pair, environment 16 | freeze 17 | end 18 | 19 | def dynamic 20 | String(value).then { |path| Pathname path } 21 | .then { |path| [path.expand_path, standard.dynamic] } 22 | end 23 | 24 | def to_s 25 | [standard.key, dynamic.join(XDG::Paths::Directory::DELIMITER)].compact.join XDG::DELIMITER 26 | end 27 | 28 | alias to_str to_s 29 | 30 | def inspect = "#<#{self.class}:#{object_id} #{self}>" 31 | 32 | private 33 | 34 | attr_reader :standard 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/runcom/state.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | 5 | module Runcom 6 | # A developer friendly wrapper of XDG state. 7 | class State 8 | extend Forwardable 9 | 10 | CONTEXT = Context.new xdg: XDG::State 11 | 12 | delegate %i[initial namespace file_name active passive global local all to_s to_str] => :common 13 | 14 | def initialize path, context: CONTEXT 15 | @common = Paths::Common.new(path, context:) 16 | freeze 17 | end 18 | 19 | def inspect = "#<#{self.class}:#{object_id} #{common}>" 20 | 21 | private 22 | 23 | attr_reader :common 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /runcom.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "runcom" 5 | spec.version = "12.2.1" 6 | spec.authors = ["Brooke Kuhlmann"] 7 | spec.email = ["brooke@alchemists.io"] 8 | spec.homepage = "https://alchemists.io/projects/runcom" 9 | spec.summary = "A XDG enhanced run command manager for command line interfaces." 10 | spec.license = "Hippocratic-2.1" 11 | 12 | spec.metadata = { 13 | "bug_tracker_uri" => "https://github.com/bkuhlmann/runcom/issues", 14 | "changelog_uri" => "https://alchemists.io/projects/runcom/versions", 15 | "homepage_uri" => "https://alchemists.io/projects/runcom", 16 | "funding_uri" => "https://github.com/sponsors/bkuhlmann", 17 | "label" => "Runcom", 18 | "rubygems_mfa_required" => "true", 19 | "source_code_uri" => "https://github.com/bkuhlmann/runcom" 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 | spec.add_dependency "xdg", "~> 9.0" 28 | spec.add_dependency "zeitwerk", "~> 2.7" 29 | 30 | spec.files = Dir["*.gemspec", "lib/**/*"] 31 | spec.extra_rdoc_files = Dir["README*", "LICENSE*"] 32 | end 33 | -------------------------------------------------------------------------------- /spec/lib/runcom/cache_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Runcom::Cache do 6 | using Refinements::Pathname 7 | 8 | subject(:cache) { described_class.new path, context: } 9 | 10 | include_context "with temporary directory" 11 | 12 | let(:path) { Pathname "test/example.txt" } 13 | let(:home_dir) { temp_dir.join ".cache" } 14 | let(:context) { Runcom::Context.new xdg: XDG::Cache, environment: } 15 | 16 | let :environment do 17 | { 18 | "HOME" => "/home", 19 | "XDG_CACHE_HOME" => home_dir 20 | } 21 | end 22 | 23 | describe "#initialize" do 24 | it "is frozen" do 25 | expect(cache.frozen?).to be(true) 26 | end 27 | end 28 | 29 | describe "#initial" do 30 | it "answers initial path" do 31 | expect(cache.initial).to eq(path) 32 | end 33 | end 34 | 35 | describe "#namespace" do 36 | it "answers namespace" do 37 | expect(cache.namespace).to eq(Pathname("test")) 38 | end 39 | end 40 | 41 | describe "#file_name" do 42 | it "answers file name" do 43 | expect(cache.file_name).to eq(Pathname("example.txt")) 44 | end 45 | end 46 | 47 | describe "#active" do 48 | it "answers file path when it exists" do 49 | file_path = home_dir.join(path).deep_touch 50 | expect(cache.active).to eq(file_path) 51 | end 52 | end 53 | 54 | describe "#passive" do 55 | it "answers global path when it doesn't exist" do 56 | expect(cache.passive).to eq(temp_dir.join(".cache", path)) 57 | end 58 | end 59 | 60 | describe "#global" do 61 | it "answers global path" do 62 | expect(cache.global).to eq(temp_dir.join(".cache", path)) 63 | end 64 | end 65 | 66 | describe "#local" do 67 | it "answers local path" do 68 | expect(cache.local).to eq(Bundler.root.join(".cache", path)) 69 | end 70 | end 71 | 72 | describe "#all" do 73 | it "answers all paths" do 74 | expect(cache.all).to eq( 75 | [ 76 | Bundler.root.join(".cache/test/example.txt"), 77 | home_dir.join(path) 78 | ] 79 | ) 80 | end 81 | end 82 | 83 | shared_examples "a string" do |message| 84 | it "answers environment settings" do 85 | expect(cache.public_send(message)).to eq( 86 | "XDG_CACHE_HOME=#{Bundler.root.join ".cache"}:#{home_dir}" 87 | ) 88 | end 89 | end 90 | 91 | describe "#to_s" do 92 | it_behaves_like "a string", :to_s 93 | end 94 | 95 | describe "#to_str" do 96 | it_behaves_like "a string", :to_str 97 | end 98 | 99 | describe "#inspect" do 100 | let :pattern do 101 | / 102 | \A 103 | \#< 104 | #{described_class}:\d+\s 105 | XDG_CACHE_HOME=#{Bundler.root.join ".cache"}:#{home_dir} 106 | > 107 | \Z 108 | /x 109 | end 110 | 111 | it "answers current environment" do 112 | expect(cache.inspect).to match(pattern) 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /spec/lib/runcom/config_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Runcom::Config do 6 | using Refinements::Pathname 7 | 8 | subject(:config) { described_class.new path, context: } 9 | 10 | include_context "with temporary directory" 11 | 12 | let(:path) { Pathname "test/config.yml" } 13 | let(:config_path) { config_home.join path } 14 | let(:config_home) { temp_dir.join ".config" } 15 | 16 | let :environment do 17 | { 18 | "HOME" => "/home", 19 | "XDG_CONFIG_HOME" => config_home, 20 | "XDG_CONFIG_DIRS" => "#{temp_dir}/one:#{temp_dir}/two" 21 | } 22 | end 23 | 24 | let(:context) { Runcom::Context.new xdg: XDG::Config, environment: } 25 | 26 | describe "#initialize" do 27 | it "is frozen" do 28 | expect(config.frozen?).to be(true) 29 | end 30 | end 31 | 32 | describe "#initial" do 33 | it "answers initial path" do 34 | expect(config.initial).to eq(path) 35 | end 36 | end 37 | 38 | describe "#namespace" do 39 | it "answers namespace" do 40 | expect(config.namespace).to eq(Pathname("test")) 41 | end 42 | end 43 | 44 | describe "#file_name" do 45 | it "answers file name" do 46 | expect(config.file_name).to eq(Pathname("config.yml")) 47 | end 48 | end 49 | 50 | describe "#active" do 51 | it "answers config file when path exists" do 52 | config_path.deep_touch 53 | expect(config.active).to eq(config_path) 54 | end 55 | end 56 | 57 | describe "#passive" do 58 | it "answers global path when it doesn't exist" do 59 | expect(config.passive).to eq(temp_dir.join(".config", path)) 60 | end 61 | end 62 | 63 | describe "#global" do 64 | it "answers global path" do 65 | expect(config.global).to eq(temp_dir.join(".config", path)) 66 | end 67 | end 68 | 69 | describe "#local" do 70 | it "answers local path" do 71 | expect(config.local).to eq(Bundler.root.join(".config", path)) 72 | end 73 | end 74 | 75 | describe "#all" do 76 | it "answers all paths" do 77 | expect(config.all).to eq( 78 | [ 79 | Bundler.root.join(".config/test/config.yml"), 80 | config_home.join("test", "config.yml"), 81 | temp_dir.join("one", "test", "config.yml"), 82 | temp_dir.join("two", "test", "config.yml") 83 | ] 84 | ) 85 | end 86 | end 87 | 88 | shared_examples "a string" do |message| 89 | it "answers environment settings" do 90 | expect(config.public_send(message)).to eq( 91 | "XDG_CONFIG_HOME=#{Bundler.root.join ".config"}:#{config_home} " \ 92 | "XDG_CONFIG_DIRS=#{temp_dir}/one:#{temp_dir}/two" 93 | ) 94 | end 95 | end 96 | 97 | describe "#to_s" do 98 | it_behaves_like "a string", :to_s 99 | end 100 | 101 | describe "#to_str" do 102 | it_behaves_like "a string", :to_str 103 | end 104 | 105 | describe "#inspect" do 106 | let :pattern do 107 | %r( 108 | \A 109 | \#< 110 | #{described_class}:\d+\s 111 | XDG_CONFIG_HOME=#{Bundler.root.join ".config"}:#{config_home}\s 112 | XDG_CONFIG_DIRS=#{temp_dir}/one:#{temp_dir}/two 113 | > 114 | \Z 115 | )x 116 | end 117 | 118 | it "answers current environment" do 119 | expect(config.inspect).to match(pattern) 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /spec/lib/runcom/context_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Runcom::Context do 6 | describe "#initialize" do 7 | it "answers default context" do 8 | expect(described_class.new).to have_attributes( 9 | home: Runcom::Paths::Home, 10 | environment: ENV, 11 | xdg: nil 12 | ) 13 | end 14 | 15 | it "answers custom context XDG class" do 16 | context = described_class.new home: XDG::Paths::Home, environment: {b: 2}, xdg: XDG::Config 17 | 18 | expect(context).to have_attributes( 19 | home: XDG::Paths::Home, 20 | environment: {b: 2}, 21 | xdg: XDG::Config 22 | ) 23 | end 24 | 25 | it "answers custom context XDG instance" do 26 | xdg = XDG::Config.new 27 | context = described_class.new(home: XDG::Paths::Home, environment: {b: 2}, xdg:) 28 | 29 | expect(context).to have_attributes(home: XDG::Paths::Home, environment: {b: 2}, xdg:) 30 | end 31 | 32 | it "answers frozen instance" do 33 | expect(described_class.new.frozen?).to be(true) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/lib/runcom/data_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Runcom::Data do 6 | using Refinements::Pathname 7 | 8 | subject(:data) { described_class.new path, context: } 9 | 10 | include_context "with temporary directory" 11 | 12 | let(:path) { Pathname "test/example.txt" } 13 | let(:home_dir) { temp_dir.join "data" } 14 | let(:context) { Runcom::Context.new xdg: XDG::Data, environment: } 15 | 16 | let :environment do 17 | { 18 | "HOME" => "/home", 19 | "XDG_DATA_HOME" => home_dir, 20 | "XDG_DATA_DIRS" => "#{temp_dir}/one:#{temp_dir}/two" 21 | } 22 | end 23 | 24 | describe "#initialize" do 25 | it "is frozen" do 26 | expect(data.frozen?).to be(true) 27 | end 28 | end 29 | 30 | describe "#initial" do 31 | it "answers initial path" do 32 | expect(data.initial).to eq(path) 33 | end 34 | end 35 | 36 | describe "#namespace" do 37 | it "answers namespace" do 38 | expect(data.namespace).to eq(Pathname("test")) 39 | end 40 | end 41 | 42 | describe "#file_name" do 43 | it "answers file name" do 44 | expect(data.file_name).to eq(Pathname("example.txt")) 45 | end 46 | end 47 | 48 | describe "#active" do 49 | it "answers file path when it exists" do 50 | file_path = home_dir.join(path).deep_touch 51 | expect(data.active).to eq(file_path) 52 | end 53 | end 54 | 55 | describe "#passive" do 56 | it "answers global path when it doesn't exist" do 57 | expect(data.passive).to eq(temp_dir.join("data", path)) 58 | end 59 | end 60 | 61 | describe "#global" do 62 | it "answers global path" do 63 | expect(data.global).to eq(temp_dir.join("data", path)) 64 | end 65 | end 66 | 67 | describe "#local" do 68 | it "answers local path" do 69 | expect(data.local).to eq(Bundler.root.join(".local/share", path)) 70 | end 71 | end 72 | 73 | describe "#all" do 74 | it "answers all paths" do 75 | expect(data.all).to contain_exactly( 76 | Bundler.root.join(".local/share/test/example.txt"), 77 | home_dir.join(path), 78 | temp_dir.join("one", path), 79 | temp_dir.join("two", path) 80 | ) 81 | end 82 | end 83 | 84 | shared_examples "a string" do |message| 85 | it "answers environment settings" do 86 | expect(data.public_send(message)).to eq( 87 | "XDG_DATA_HOME=#{Bundler.root.join ".local/share"}:#{home_dir} " \ 88 | "XDG_DATA_DIRS=#{temp_dir}/one:#{temp_dir}/two" 89 | ) 90 | end 91 | end 92 | 93 | describe "#to_s" do 94 | it_behaves_like "a string", :to_s 95 | end 96 | 97 | describe "#to_str" do 98 | it_behaves_like "a string", :to_str 99 | end 100 | 101 | describe "#inspect" do 102 | let :pattern do 103 | %r( 104 | \A 105 | \#< 106 | #{described_class}:\d+\s 107 | XDG_DATA_HOME=#{Bundler.root.join ".local/share"}:#{home_dir}\s 108 | XDG_DATA_DIRS=#{temp_dir}/one:#{temp_dir}/two 109 | > 110 | \Z 111 | )x 112 | end 113 | 114 | it "answers current environment" do 115 | expect(data.inspect).to match(pattern) 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /spec/lib/runcom/paths/common_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Runcom::Paths::Common do 6 | using Refinements::Pathname 7 | 8 | subject :path do 9 | described_class.new test_path, context: Runcom::Context.new(xdg: XDG::Data, environment:) 10 | end 11 | 12 | include_context "with temporary directory" 13 | 14 | let(:test_path) { "test/example.txt" } 15 | 16 | let :environment do 17 | { 18 | "HOME" => "/home", 19 | "XDG_DATA_HOME" => temp_dir, 20 | "XDG_DATA_DIRS" => "#{temp_dir}/one:#{temp_dir}/two" 21 | } 22 | end 23 | 24 | describe "#initialize" do 25 | it "is frozen" do 26 | expect(path.frozen?).to be(true) 27 | end 28 | end 29 | 30 | describe "#initial" do 31 | it "answers initial path when resembling a path" do 32 | expect(path.initial).to eq(Pathname("test/example.txt")) 33 | end 34 | 35 | context "with empty string" do 36 | let(:test_path) { "" } 37 | 38 | it "answers empty path" do 39 | expect(path.initial).to eq(Pathname("")) 40 | end 41 | end 42 | 43 | context "with nil" do 44 | let(:test_path) { nil } 45 | 46 | it "answers empty path" do 47 | expect(path.initial).to eq(Pathname("")) 48 | end 49 | end 50 | 51 | context "with pathname" do 52 | let(:test_path) { Pathname "test/example.txt" } 53 | 54 | it "answers initial path" do 55 | expect(path.initial).to eq(Pathname("test/example.txt")) 56 | end 57 | end 58 | end 59 | 60 | describe "#namespace" do 61 | it "answers parent directory with relative path" do 62 | expect(path.namespace).to eq(Pathname("test")) 63 | end 64 | 65 | context "with empty path" do 66 | let(:test_path) { "" } 67 | 68 | it "answers parent directory" do 69 | expect(path.namespace).to eq(Pathname("..")) 70 | end 71 | end 72 | end 73 | 74 | describe "#file_name" do 75 | it "answers file name" do 76 | expect(path.file_name).to eq(Pathname("example.txt")) 77 | end 78 | 79 | context "with empty path" do 80 | let(:test_path) { "" } 81 | 82 | it "answers empty file name" do 83 | expect(path.file_name).to eq(Pathname("")) 84 | end 85 | end 86 | end 87 | 88 | describe "#active" do 89 | it "answers path when file exists" do 90 | file_path = temp_dir.join("test").mkpath.join("example.txt").touch 91 | expect(path.active).to eq(file_path) 92 | end 93 | 94 | it "answers nil when path is a directory" do 95 | temp_dir.join("one/test/example.txt").mkpath 96 | expect(path.active).to be(nil) 97 | end 98 | 99 | it "answers nil when path doesn't exist" do 100 | expect(path.active).to be(nil) 101 | end 102 | 103 | context "with empty path" do 104 | let(:test_path) { "" } 105 | 106 | it "answers nil" do 107 | expect(path.active).to be(nil) 108 | end 109 | end 110 | end 111 | 112 | describe "#passive" do 113 | it "answers active path when path exists" do 114 | file_path = temp_dir.join("test").mkpath.join("example.txt").touch 115 | expect(path.passive).to eq(file_path) 116 | end 117 | 118 | it "answers passive path when path doesn't exist" do 119 | expect(path.passive).to eq(temp_dir.join(test_path)) 120 | end 121 | 122 | context "with empty path" do 123 | let(:test_path) { "" } 124 | 125 | it "answers global path" do 126 | expect(path.passive).to eq(temp_dir) 127 | end 128 | end 129 | 130 | context "with nil path" do 131 | let(:test_path) { nil } 132 | 133 | it "answers global path" do 134 | expect(path.passive).to eq(temp_dir) 135 | end 136 | end 137 | end 138 | 139 | describe "#global" do 140 | it "answers global path" do 141 | expect(path.global).to eq(temp_dir.join("test/example.txt")) 142 | end 143 | end 144 | 145 | describe "#local" do 146 | it "relative path as expanded path" do 147 | expect(path.local).to eq(Pathname.pwd.join(".local/share/test/example.txt")) 148 | end 149 | end 150 | 151 | describe "#all" do 152 | it "answers paths with namespace and file path" do 153 | expect(path.all).to eq( 154 | [ 155 | Bundler.root.join(".local/share/test/example.txt"), 156 | temp_dir.join("test/example.txt"), 157 | temp_dir.join("one/test/example.txt"), 158 | temp_dir.join("two/test/example.txt") 159 | ] 160 | ) 161 | end 162 | 163 | context "with namespace only" do 164 | let(:test_path) { "test" } 165 | 166 | it "answers paths with namespace only" do 167 | expect(path.all).to eq( 168 | [ 169 | Bundler.root.join(".local/share/test"), 170 | temp_dir.join("test"), 171 | temp_dir.join("one", "test"), 172 | temp_dir.join("two", "test") 173 | ] 174 | ) 175 | end 176 | end 177 | 178 | context "with empty path" do 179 | let(:test_path) { "" } 180 | 181 | it "answes something" do 182 | expect(path.all).to eq( 183 | [ 184 | Bundler.root.join(".local/share"), 185 | temp_dir, 186 | temp_dir.join("one"), 187 | temp_dir.join("two") 188 | ] 189 | ) 190 | end 191 | end 192 | end 193 | 194 | shared_examples "a string" do |message| 195 | it "answers environment settings" do 196 | expect(path.public_send(message)).to eq( 197 | "XDG_DATA_HOME=#{Bundler.root}/.local/share:#{temp_dir} " \ 198 | "XDG_DATA_DIRS=#{temp_dir}/one:#{temp_dir}/two" 199 | ) 200 | end 201 | end 202 | 203 | describe "#to_s" do 204 | it_behaves_like "a string", :to_s 205 | end 206 | 207 | describe "#to_str" do 208 | it_behaves_like "a string", :to_str 209 | end 210 | 211 | describe "#inspect" do 212 | let :pattern do 213 | %r( 214 | \A 215 | \#< 216 | #{described_class}:\d+\s 217 | XDG_DATA_HOME=#{Bundler.root}/.local/share:#{temp_dir}\s 218 | XDG_DATA_DIRS=#{temp_dir}/one:#{temp_dir}/two 219 | > 220 | \Z 221 | )x 222 | end 223 | 224 | it "answers current environment" do 225 | expect(path.inspect).to match(pattern) 226 | end 227 | end 228 | end 229 | -------------------------------------------------------------------------------- /spec/lib/runcom/paths/home_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Runcom::Paths::Home do 6 | using Refinements::Pathname 7 | 8 | subject(:path) { described_class.new pair, environment } 9 | 10 | include_context "with temporary directory" 11 | 12 | let(:pair) { XDG::Pair.new "TEST", "test" } 13 | let(:home) { XDG::Pair.new "HOME", "/home" } 14 | let(:environment) { home.to_env } 15 | 16 | describe "#initialize" do 17 | it "is frozen" do 18 | expect(path.frozen?).to be(true) 19 | end 20 | end 21 | 22 | describe "#key" do 23 | it "answers key" do 24 | expect(path.key).to eq(pair.key) 25 | end 26 | end 27 | 28 | describe "#value" do 29 | it "answers value" do 30 | expect(path.value).to eq(pair.value) 31 | end 32 | end 33 | 34 | describe "#default" do 35 | it "answers relative path" do 36 | expect(path.default).to eq(Pathname("/home/test")) 37 | end 38 | end 39 | 40 | describe "#dynamic" do 41 | context "with default path" do 42 | let(:environment) { home.to_env.merge pair.to_env } 43 | 44 | it "answers default path" do 45 | expect(path.dynamic).to eq([Bundler.root.join("test"), Pathname("/home/test")]) 46 | end 47 | end 48 | 49 | context "with dynamic path" do 50 | let(:environment) { home.to_env.merge pair.key => "dynamic" } 51 | 52 | it "answers dynamic path" do 53 | expect(path.dynamic).to eq([Bundler.root.join("test"), Pathname("/home/dynamic")]) 54 | end 55 | end 56 | 57 | context "with existing path" do 58 | let(:environment) { home.to_env.merge pair.to_env } 59 | let(:test_path) { temp_dir.join "test" } 60 | 61 | before { test_path.mkpath } 62 | 63 | it "answers dynamic path" do 64 | temp_dir.change_dir { expect(path.dynamic).to eq([test_path, Pathname("/home/test")]) } 65 | end 66 | end 67 | end 68 | 69 | shared_examples "a string" do |message| 70 | it "answers key and value" do 71 | expect(path.public_send(message)).to eq(%(TEST=#{Bundler.root.join "test"}:/home/test)) 72 | end 73 | 74 | context "with empty pair" do 75 | let(:pair) { XDG::Pair.new } 76 | 77 | it "answers value only" do 78 | expect(path.public_send(message)).to eq(%("#{Bundler.root}:/home").undump) 79 | end 80 | end 81 | end 82 | 83 | describe "#to_s" do 84 | it_behaves_like "a string", :to_s 85 | end 86 | 87 | describe "#inspect" do 88 | it "answers key and value with custom pair" do 89 | expect(path.inspect).to match( 90 | %r(\A\#<#{described_class}:\d+ TEST=#{Bundler.root.join "test"}:/home/test>\Z) 91 | ) 92 | end 93 | 94 | context "with empty pair" do 95 | let(:pair) { XDG::Pair.new } 96 | 97 | it "answers value only" do 98 | expect(path.inspect).to match(%r(\A\#<#{described_class}:\d+ #{Bundler.root}:/home>\Z)) 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /spec/lib/runcom/state_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Runcom::State do 6 | using Refinements::Pathname 7 | 8 | subject(:state) { described_class.new path, context: } 9 | 10 | include_context "with temporary directory" 11 | 12 | let(:path) { Pathname "test/example.txt" } 13 | let(:home_dir) { temp_dir.join ".state" } 14 | let(:context) { Runcom::Context.new xdg: XDG::State, environment: } 15 | 16 | let :environment do 17 | { 18 | "HOME" => "/home", 19 | "XDG_STATE_HOME" => home_dir 20 | } 21 | end 22 | 23 | describe "#initialize" do 24 | it "is frozen" do 25 | expect(state.frozen?).to be(true) 26 | end 27 | end 28 | 29 | describe "#initial" do 30 | it "answers initial path" do 31 | expect(state.initial).to eq(path) 32 | end 33 | end 34 | 35 | describe "#namespace" do 36 | it "answers namespace" do 37 | expect(state.namespace).to eq(Pathname("test")) 38 | end 39 | end 40 | 41 | describe "#file_name" do 42 | it "answers file name" do 43 | expect(state.file_name).to eq(Pathname("example.txt")) 44 | end 45 | end 46 | 47 | describe "#active" do 48 | it "answers file path when it exists" do 49 | file_path = home_dir.join(path).deep_touch 50 | expect(state.active).to eq(file_path) 51 | end 52 | end 53 | 54 | describe "#passive" do 55 | it "answers global path when it doesn't exist" do 56 | expect(state.passive).to eq(temp_dir.join(".state", path)) 57 | end 58 | end 59 | 60 | describe "#global" do 61 | it "answers global path" do 62 | expect(state.global).to eq(temp_dir.join(".state", path)) 63 | end 64 | end 65 | 66 | describe "#local" do 67 | it "answers local path" do 68 | expect(state.local).to eq(Bundler.root.join(".local/state", path)) 69 | end 70 | end 71 | 72 | describe "#all" do 73 | it "answers all paths" do 74 | expect(state.all).to eq( 75 | [ 76 | Bundler.root.join(".local/state/test/example.txt"), 77 | home_dir.join(path) 78 | ] 79 | ) 80 | end 81 | end 82 | 83 | shared_examples "a string" do |message| 84 | it "answers environment settings" do 85 | expect(state.public_send(message)).to eq( 86 | "XDG_STATE_HOME=#{Bundler.root.join ".local/state"}:#{home_dir}" 87 | ) 88 | end 89 | end 90 | 91 | describe "#to_s" do 92 | it_behaves_like "a string", :to_s 93 | end 94 | 95 | describe "#to_str" do 96 | it_behaves_like "a string", :to_str 97 | end 98 | 99 | describe "#inspect" do 100 | let :pattern do 101 | / 102 | \A 103 | \#< 104 | #{described_class}:\d+\s 105 | XDG_STATE_HOME=#{Bundler.root.join ".local/state"}:#{home_dir} 106 | > 107 | \Z 108 | /x 109 | end 110 | 111 | it "answers current environment" do 112 | expect(state.inspect).to match(pattern) 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /spec/lib/runcom_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Runcom do 6 | describe ".loader" do 7 | it "eager loads" do 8 | expectation = proc { described_class.loader.eager_load force: true } 9 | expect(&expectation).not_to raise_error 10 | end 11 | 12 | it "answers unique tag" do 13 | expect(described_class.loader.tag).to eq("runcom") 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /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 "refinements" 17 | require "runcom" 18 | 19 | SPEC_ROOT = Pathname(__dir__).realpath.freeze 20 | 21 | using Refinements::Pathname 22 | 23 | Pathname.require_tree SPEC_ROOT.join("support/shared_contexts") 24 | 25 | RSpec.configure do |config| 26 | config.color = true 27 | config.disable_monkey_patching! 28 | config.example_status_persistence_file_path = "./tmp/rspec-examples.txt" 29 | config.filter_run_when_matching :focus 30 | config.formatter = ENV.fetch("CI", false) == "true" ? :progress : :documentation 31 | config.order = :random 32 | config.pending_failure_output = :no_backtrace 33 | config.shared_context_metadata_behavior = :apply_to_host_groups 34 | config.warnings = true 35 | 36 | config.expect_with :rspec do |expectations| 37 | expectations.syntax = :expect 38 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 39 | end 40 | 41 | config.mock_with :rspec do |mocks| 42 | mocks.verify_doubled_constant_names = true 43 | mocks.verify_partial_doubles = true 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/support/fixtures/invalid.yml: -------------------------------------------------------------------------------- 1 | :colors: 2 | - "red" 3 | - "black", 4 | - "white" 5 | -------------------------------------------------------------------------------- /spec/support/shared_contexts/temp_dir.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context "with temporary directory" do 4 | let(:temp_dir) { Bundler.root.join "tmp/rspec" } 5 | 6 | around do |example| 7 | temp_dir.mkpath 8 | example.run 9 | temp_dir.rmtree 10 | end 11 | end 12 | --------------------------------------------------------------------------------