floss_funding 1.0.0.pre.alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ # std libs
4
+ require "openssl"
5
+
6
+ module FlossFunding
7
+ # This module loads inside an anonymous module on Ruby 3.1+.
8
+ # This is why FlossFunding herein uses top-level namespace as `::FlossFunding`.
9
+ module Check
10
+ # When this module is included, extend the target with class-level helpers
11
+ # and set the deterministic time source (used in specs via Timecop).
12
+ #
13
+ # @param base [Module] the including module
14
+ # @param now [Time] the current time (defaults to Time.now)
15
+ # @return [void]
16
+ def self.included(base, now = Time.now)
17
+ base.extend(ClassMethods)
18
+ ClassMethods.now_time = now
19
+ end
20
+
21
+ # When this module is extended, also extend with class-level helpers and
22
+ # set the deterministic time source.
23
+ #
24
+ # @param base [Module] the extending module
25
+ # @param now [Time] the current time (defaults to Time.now)
26
+ # @return [void]
27
+ def self.extended(base, now = Time.now)
28
+ base.extend(ClassMethods)
29
+ ClassMethods.now_time = now
30
+ end
31
+
32
+ # Class-level API used by FlossFunding::Poke to perform activation checks
33
+ # and generate user-facing messages. Methods here are intended for inclusion
34
+ # into client libraries when they `extend FlossFunding::Check`.
35
+ module ClassMethods
36
+ class << self
37
+ # Time source used for month arithmetic and testing.
38
+ # @return [Time]
39
+ attr_accessor :now_time
40
+ end
41
+
42
+ # Decrypts a hex-encoded activation key using a namespace-derived key.
43
+ #
44
+ # @param activation_key [String] 64-character hex string for paid activation
45
+ # @param namespace [String] the namespace used to derive the cipher key
46
+ # @return [String, false] plaintext activation key (base word) on success; false if empty
47
+ def floss_funding_decrypt(activation_key, namespace)
48
+ return false if activation_key.empty?
49
+
50
+ cipher = OpenSSL::Cipher.new("aes-256-cbc").decrypt
51
+ cipher.key = Digest::MD5.hexdigest(namespace)
52
+ s = [activation_key].pack("H*")
53
+
54
+ cipher.update(s) + cipher.final
55
+ end
56
+
57
+ # Returns true for unpaid or opted-out activation_key that
58
+ # should not emit any console output (silent success).
59
+ # Otherwise false.
60
+ #
61
+ # @param activation_key [String]
62
+ # @param namespace [String]
63
+ # @return [Boolean]
64
+ def check_unpaid_silence(activation_key, namespace)
65
+ case activation_key
66
+ when ::FlossFunding::FREE_AS_IN_BEER, ::FlossFunding::BUSINESS_IS_NOT_GOOD_YET, "#{::FlossFunding::NOT_FINANCIALLY_SUPPORTING}-#{namespace}"
67
+ # Configured as unpaid
68
+ true
69
+ else
70
+ # Might be configured as paid
71
+ false
72
+ end
73
+ end
74
+
75
+ # Returns the list of valid plain text base words for the current month window.
76
+ #
77
+ # @return [Array<String>]
78
+ def base_words
79
+ ::FlossFunding.base_words(num_valid_words_for_month)
80
+ end
81
+
82
+ # Checks whether the given plaintext matches a valid plaintext base word.
83
+ #
84
+ # @param plain_text [String]
85
+ # @return [Boolean]
86
+ def check_activation(plain_text)
87
+ words = base_words
88
+ # Use fast binary search when available (Ruby >= 2.0), else fall back to include?
89
+ # We can't run CI on Ruby < 2.3 so the alternate branch is not going to have test coverage.
90
+ # :nocov:
91
+ if words.respond_to?(:bsearch)
92
+ !!words.bsearch { |word| plain_text == word }
93
+ else
94
+ words.include?(plain_text)
95
+ end
96
+ # :nocov:
97
+ end
98
+
99
+ # Entry point for activation key evaluation and output behavior.
100
+ #
101
+ # @param activation_key [String] value from ENV
102
+ # @param namespace [String] this activation key is valid for a specific namespace; can cover multiple projects / gems
103
+ # @param env_var_name [String] the ENV variable name checked
104
+ # @return [void]
105
+ def floss_funding_initiate_begging(activation_key, namespace, env_var_name)
106
+ if activation_key.empty?
107
+ # No activation key provided
108
+ ::FlossFunding.add_unactivated(namespace)
109
+ return start_begging(namespace, env_var_name)
110
+ end
111
+
112
+ # A silent short circuit for valid unpaid activations
113
+ if check_unpaid_silence(activation_key, namespace)
114
+ ::FlossFunding.add_activated(namespace)
115
+ ::FlossFunding.add_activation_occurrence(namespace)
116
+ return
117
+ end
118
+
119
+ valid_activation_hex = !!(activation_key =~ ::FlossFunding::HEX_LICENSE_RULE)
120
+ unless valid_activation_hex
121
+ # Invalid activation key format
122
+ ::FlossFunding.add_unactivated(namespace)
123
+ return start_coughing(activation_key, namespace, env_var_name)
124
+ end
125
+
126
+ # decrypt the activation key for this namespace
127
+ plain_text = floss_funding_decrypt(activation_key, namespace)
128
+
129
+ # A silent short circuit for valid paid activation keys
130
+ if check_activation(plain_text)
131
+ ::FlossFunding.add_activated(namespace)
132
+ ::FlossFunding.add_activation_occurrence(namespace)
133
+ return
134
+ end
135
+
136
+ # No valid activation key found
137
+ ::FlossFunding.add_unactivated(namespace)
138
+ start_begging(namespace, env_var_name)
139
+ end
140
+
141
+ private
142
+
143
+ # Using the month gem to easily do month math.
144
+ #
145
+ # @return [Integer] number of valid words based on the month offset
146
+ def num_valid_words_for_month
147
+ now_month - ::FlossFunding::START_MONTH
148
+ end
149
+
150
+ # Returns the Month integer for the configured time source.
151
+ #
152
+ # @return [Integer]
153
+ def now_month
154
+ Month.new(ClassMethods.now_time.year, ClassMethods.now_time.month).to_i
155
+ end
156
+
157
+ # Emits a diagnostic message for invalid activation key format.
158
+ #
159
+ # @param activation_key [String]
160
+ # @param namespace [String]
161
+ # @param env_var_name [String]
162
+ # @return [void]
163
+ def start_coughing(activation_key, namespace, env_var_name)
164
+ puts <<-COUGHING
165
+ ==============================================================
166
+ COUGH, COUGH.
167
+ Ahem, it appears as though you tried to set an activation key
168
+ for #{namespace}, but it was invalid.
169
+
170
+ Current (Invalid) Activation Key: #{activation_key}
171
+ Namespace: #{namespace}
172
+ ENV Variable: #{env_var_name}
173
+
174
+ Paid activation keys are 8 bytes, 64 hex characters, long.
175
+ Unpaid activation keys have varying lengths, depending on type and namespace.
176
+ Yours is #{activation_key.length} characters long, and doesn't match any paid or unpaid keys.
177
+
178
+ Please unset the current ENV variable #{env_var_name}, since it is invalid.
179
+
180
+ Then find the correct one, or get a new one @ https://floss-funding.dev and set it.
181
+
182
+ #{FlossFunding::FOOTER}
183
+ COUGHING
184
+ end
185
+
186
+ # Emits the standard friendly funding message for unactivated usage.
187
+ #
188
+ # @param namespace [String]
189
+ # @param env_var_name [String]
190
+ # @return [void]
191
+ def start_begging(namespace, env_var_name)
192
+ # During load, only emit a single-line note and defer the large blurb to at_exit
193
+ puts %(FLOSS Funding: Activation key missing for #{namespace}. Set ENV[#{env_var_name}] to activation key; details will be shown at exit.)
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "rubygems" # For Gem::Specification
5
+
6
+ module FlossFunding
7
+ # Handles configuration loading from a .floss_funding.yml file located at the
8
+ # root of the including project (heuristically discovered by walking upward
9
+ # from the including file path until a Gemfile or *.gemspec is found).
10
+ #
11
+ # All APIs in this module require the including file path (e.g., `__FILE__`).
12
+ #
13
+ # The loaded config is merged over DEFAULT_CONFIG, so any unspecified keys fall
14
+ # back to defaults.
15
+ module Config
16
+ # The file name to look for in the project root.
17
+ # @return [String]
18
+ CONFIG_FILE_NAME = ".floss_funding.yml"
19
+
20
+ # Default configuration values for FlossFunding prompting.
21
+ # Also includes slots for gemspec-derived attributes we track per gem.
22
+ # @return [Hash{String=>Object}]
23
+ DEFAULT_CONFIG = {
24
+ "suggested_donation_amount" => [5],
25
+ "floss_funding_url" => ["https://floss-funding.dev"],
26
+ # Optional namespace override for when including without explicit namespace
27
+ # When set (non-empty string), this will be used as the namespace instead of the including module's name
28
+ "namespace" => [],
29
+ # Track both the base.name namespace(s) and any custom namespace(s) passed to Poke.new
30
+ "base_namespaces" => [],
31
+ "custom_namespaces" => [],
32
+ # Gemspec-derived attributes (nil when unknown)
33
+ "gem_name" => [],
34
+ "homepage" => [],
35
+ "authors" => [],
36
+ "funding_uri" => [],
37
+ }.freeze
38
+
39
+ class << self
40
+ # Loads configuration from .floss_funding.yml by walking up from the
41
+ # provided including file path to discover the project root.
42
+ #
43
+ # @param including_path [String] the including file path (e.g., __FILE__)
44
+ # @return [Hash{String=>Object}] configuration hash with defaults merged
45
+ # @raise [::FlossFunding::Error] if including_path is not a String
46
+ def load_config(including_path)
47
+ unless including_path.is_a?(String)
48
+ raise ::FlossFunding::Error, "including must be a String file path (e.g., __FILE__), got #{including_path.class}"
49
+ end
50
+
51
+ # Discover project root (Gemfile or *.gemspec)
52
+ project_root = find_project_root(including_path)
53
+
54
+ # Load YAML config if present (respect test stubs of find_config_file)
55
+ config_file = find_config_file(including_path)
56
+ raw_config = config_file ? load_yaml_file(config_file) : {}
57
+
58
+ # Strict filter: only allow known string keys, then normalize to arrays
59
+ filtered = {}
60
+ raw_config.each do |k, v|
61
+ next unless k.is_a?(String)
62
+ next unless DEFAULT_CONFIG.key?(k)
63
+ filtered[k] = normalize_to_array(v)
64
+ end
65
+
66
+ # Load gemspec data for defaults if available
67
+ gemspec_data = project_root ? read_gemspec_data(project_root) : {}
68
+ # Prepare defaults from gemspec:
69
+ # - Store all gemspec attributes into config slots, as arrays
70
+ # - If floss_funding_url not set in YAML, default to gemspec funding_uri
71
+ gemspec_defaults = {}
72
+ unless gemspec_data.empty?
73
+ gemspec_defaults["gem_name"] = normalize_to_array(gemspec_data[:name]) # name is required by rubygems
74
+ gemspec_defaults["homepage"] = normalize_to_array(gemspec_data[:homepage]) if gemspec_data[:homepage]
75
+ gemspec_defaults["authors"] = normalize_to_array(gemspec_data[:authors]) # authors defaults to []
76
+ gemspec_defaults["funding_uri"] = normalize_to_array(gemspec_data[:funding_uri]) if gemspec_data[:funding_uri]
77
+ if gemspec_data[:funding_uri] && !filtered.key?("floss_funding_url")
78
+ gemspec_defaults["floss_funding_url"] = normalize_to_array(gemspec_data[:funding_uri])
79
+ end
80
+ end
81
+
82
+ # Merge precedence: DEFAULT < gemspec_defaults, with filtered_yaml overriding entirely when present
83
+ merged = {}
84
+ DEFAULT_CONFIG.keys.each do |key|
85
+ if filtered.key?(key)
86
+ # YAML-provided known string keys take full precedence (override defaults and gemspec values)
87
+ merged[key] = Array(filtered[key]).compact.flatten.uniq
88
+ else
89
+ # Otherwise, start from defaults and enrich with gemspec-derived values when available
90
+ merged[key] = []
91
+ merged[key].concat(Array(DEFAULT_CONFIG[key]))
92
+ merged[key].concat(Array(gemspec_defaults[key])) if gemspec_defaults.key?(key)
93
+ merged[key] = merged[key].compact.flatten.uniq
94
+ end
95
+ end
96
+ merged
97
+ end
98
+
99
+ private
100
+
101
+ # Finds the configuration file by walking up from including_path looking for
102
+ # a .floss_funding.yml file. This works for gems, Bundler projects, and
103
+ # plain Ruby projects without Gemfile/gemspec.
104
+ #
105
+ # @param including_path [String] the including file path
106
+ # @return [String, nil] absolute path to the config file or nil if not found
107
+ def find_config_file(including_path)
108
+ begin
109
+ start_dir = File.dirname(File.expand_path(including_path))
110
+ current_dir = start_dir
111
+
112
+ # Determine an upper boundary for search to avoid leaking configs from unrelated parents.
113
+ project_root = find_project_root(including_path)
114
+
115
+ # Prefer the directory that looks like the local project root (parent of lib)
116
+ lib_root = (File.basename(start_dir) == "lib") ? File.dirname(start_dir) : nil
117
+
118
+ boundary_dir = lib_root || project_root
119
+
120
+ # If we couldn't determine any reasonable boundary (very unusual), limit the
121
+ # search to the including file's directory and its immediate parent. This
122
+ # still allows patterns like lib/... including a config placed at the
123
+ # project root (parent of lib), while preventing accidental pickup of
124
+ # higher-level fixture/config files.
125
+ if boundary_dir.nil?
126
+ parent_once = File.dirname(start_dir)
127
+ while current_dir && current_dir != "/"
128
+ candidate = File.join(current_dir, CONFIG_FILE_NAME)
129
+ return candidate if File.exist?(candidate)
130
+ break if current_dir == parent_once
131
+ current_dir = File.dirname(current_dir)
132
+ end
133
+ return
134
+ end
135
+
136
+ # Search upward until the boundary (inclusive)
137
+ while current_dir && current_dir != "/"
138
+ candidate = File.join(current_dir, CONFIG_FILE_NAME)
139
+ return candidate if File.exist?(candidate)
140
+ break if current_dir == boundary_dir
141
+ current_dir = File.dirname(current_dir)
142
+ end
143
+ rescue
144
+ # Fall through to nil when any unexpected error happens
145
+ end
146
+ nil
147
+ end
148
+
149
+ # Attempts to find the root directory of the project that included
150
+ # FlossFunding::Poke by starting from the including file path and walking
151
+ # up the directory tree until a Gemfile or *.gemspec is found.
152
+ #
153
+ # @param including_path [String] the including file path
154
+ # @return [String, nil] the discovered project root directory or nil
155
+ def find_project_root(including_path)
156
+ begin
157
+ current_dir = File.dirname(File.expand_path(including_path))
158
+ while current_dir && current_dir != "/"
159
+ return current_dir if Dir.glob(File.join(current_dir, "*.gemspec")).any? || File.exist?(File.join(current_dir, "Gemfile"))
160
+ current_dir = File.dirname(current_dir)
161
+ end
162
+ rescue
163
+ nil
164
+ end
165
+ nil
166
+ end
167
+
168
+ # Loads and parses a YAML file from disk.
169
+ #
170
+ # @param file_path [String] absolute path to the YAML file
171
+ # @return [Hash] parsed YAML content or empty hash if parsing fails
172
+ def load_yaml_file(file_path)
173
+ begin
174
+ YAML.load_file(file_path) || {}
175
+ rescue
176
+ # If there's any error loading the file, return an empty hash
177
+ {}
178
+ end
179
+ end
180
+
181
+ # Reads gemspec data from the first *.gemspec in project_root using
182
+ # RubyGems API, and extracts fields of interest.
183
+ # @param project_root [String]
184
+ # @return [Hash] keys: :name, :homepage, :authors, :funding_uri
185
+ def read_gemspec_data(project_root)
186
+ gemspec_path = Dir.glob(File.join(project_root, "*.gemspec")).first
187
+ return {} unless gemspec_path
188
+ begin
189
+ spec = Gem::Specification.load(gemspec_path)
190
+ return {} unless spec
191
+ metadata = spec.metadata || {}
192
+ funding_uri = metadata["funding_uri"] || metadata[:funding_uri]
193
+ {
194
+ :name => spec.name,
195
+ :homepage => spec.homepage,
196
+ :authors => spec.authors,
197
+ :funding_uri => funding_uri,
198
+ }
199
+ rescue StandardError
200
+ {}
201
+ end
202
+ end
203
+
204
+ # Normalize a value from YAML or gemspec to an array.
205
+ # - nil => []
206
+ # - array => same array
207
+ # - scalar => [scalar]
208
+ def normalize_to_array(value)
209
+ return [] if value.nil?
210
+ return value.compact if value.is_a?(Array)
211
+ [value]
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlossFunding
4
+ # Public API for including FlossFunding into your library/module.
5
+ #
6
+ # Usage patterns:
7
+ #
8
+ # 1. Traditional namespace (uses the including module's name):
9
+ #
10
+ # module MyGemLibrary
11
+ # include FlossFunding::Poke.new(__FILE__)
12
+ # end
13
+ #
14
+ # 2. Arbitrary custom namespace (can add version, or anything else):
15
+ #
16
+ # module MyGemLibrary
17
+ # include FlossFunding::Poke.new(__FILE__, "Custom::Namespace::V4")
18
+ # end
19
+ #
20
+ # In all cases, the first parameter must be a String file path (e.g., `__FILE__`).
21
+ module Poke
22
+ # Use class << self for defining class methods
23
+ class << self
24
+ # Hook invoked when including FlossFunding::Poke directly.
25
+ #
26
+ # Direct inclusion is not supported; always use `Poke.new(__FILE__, ...)`.
27
+ #
28
+ # @param base [Module] the target including module
29
+ # @raise [::FlossFunding::Error] always, instructing correct usage
30
+ def included(base)
31
+ raise ::FlossFunding::Error, "Do not include FlossFunding::Poke directly. Use include FlossFunding::Poke.new(__FILE__, optional_namespace, optional_env_prefix)."
32
+ end
33
+
34
+ # Builds a module suitable for inclusion which sets up FlossFunding.
35
+ #
36
+ # @param including_path [String] the including file path (e.g., `__FILE__`)
37
+ # @param namespace [String, nil] optional custom namespace for activation key
38
+ # @param env_prefix [String, nil] optional ENV var prefix; defaults to
39
+ # FlossFunding::UnderBar::DEFAULT_PREFIX when nil
40
+ # @return [Module] a module that can be included into your namespace
41
+ def new(including_path, namespace = nil, env_prefix = nil)
42
+ Module.new do
43
+ define_singleton_method(:included) do |base|
44
+ FlossFunding::Poke.setup_begging(base, namespace, env_prefix, including_path)
45
+ end
46
+ end
47
+ end
48
+
49
+ # Performs common setup: extends the base with Check, computes the
50
+ # namespace and ENV var name, loads configuration, and initiates begging.
51
+ #
52
+ # @param base [Module] the module including the returned Poke module
53
+ # @param custom_namespace [String, nil] custom namespace or nil to use base.name
54
+ # @param env_prefix [String, nil] ENV var prefix or default when nil
55
+ # @param including_path [String] source file path of base (e.g., `__FILE__`)
56
+ # @return [void]
57
+ # @raise [::FlossFunding::Error] if including_path is not a String
58
+ # @raise [::FlossFunding::Error] if base.name is not a String
59
+ def setup_begging(base, custom_namespace, env_prefix, including_path)
60
+ unless including_path.is_a?(String)
61
+ raise ::FlossFunding::Error, "including_path must be a String file path (e.g., __FILE__), got #{including_path.class}"
62
+ end
63
+ unless base.respond_to?(:name) && base.name && base.name.is_a?(String)
64
+ raise ::FlossFunding::Error, "base must have a name (e.g., MyGemLibrary), got #{base.inspect}"
65
+ end
66
+
67
+ require "floss_funding/check"
68
+ # Extend the base with the checker module first
69
+ base.extend(::FlossFunding::Check)
70
+
71
+ # Load configuration from .floss_funding.yml if it exists
72
+ config = ::FlossFunding::Config.load_config(including_path)
73
+
74
+ # Three data points needed:
75
+ # 1. namespace (derived from the base class name, config, or param)
76
+ # 2. ENV variable name (derived from namespace)
77
+ # 3. activation key (derived from ENV variable)
78
+ namespace =
79
+ if custom_namespace && !custom_namespace.empty?
80
+ custom_namespace
81
+ else
82
+ base.name
83
+ end
84
+
85
+ # Track both base.name and the custom namespace (if provided) in the configuration arrays
86
+ config["base_namespaces"] ||= []
87
+ config["base_namespaces"] << base.name
88
+ config["custom_namespaces"] ||= []
89
+ config["custom_namespaces"] << custom_namespace if custom_namespace && !custom_namespace.empty?
90
+ # Deduplicate
91
+ config["base_namespaces"] = config["base_namespaces"].flatten.uniq
92
+ config["custom_namespaces"] = config["custom_namespaces"].flatten.uniq
93
+
94
+ env_var_name = ::FlossFunding::UnderBar.env_variable_name(
95
+ {
96
+ :prefix => env_prefix,
97
+ :namespace => namespace,
98
+ },
99
+ )
100
+ activation_key = ENV.fetch(env_var_name, "")
101
+
102
+ # Store configuration and ENV var name under the effective namespace
103
+ ::FlossFunding.set_configuration(namespace, config)
104
+ ::FlossFunding.set_env_var_name(namespace, env_var_name)
105
+
106
+ # Now call the begging method after extending
107
+ base.floss_funding_initiate_begging(activation_key, namespace, env_var_name)
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,57 @@
1
+ module FlossFunding
2
+ # Utilities to convert Ruby namespaces to safe, uppercased, underscore forms
3
+ # for environment variable names. Protects against malicious or invalid class
4
+ # names via conservative character rules.
5
+ #
6
+ # See also: https://github.com/galtzo-floss/shields-badge/blob/main/lib/shields/badge.rb
7
+ module UnderBar
8
+ # Default ENV prefix used when none is provided.
9
+ # @return [String]
10
+ DEFAULT_PREFIX = "FLOSS_FUNDING_"
11
+
12
+ # Allowed characters for a single namespace segment. Max length 256 to avoid abuse.
13
+ # @return [Regexp]
14
+ SAFE_TO_UNDERSCORE = /\A[\p{UPPERCASE-LETTER}\p{LOWERCASE-LETTER}\p{DECIMAL-NUMBER}]{1,256}\Z/
15
+
16
+ # Pattern to insert underscores before capital letters.
17
+ # @return [Regexp]
18
+ SUBBER_UNDER = /(\p{UPPERCASE-LETTER})/
19
+
20
+ # Pattern for a leading underscore to be removed after transformation.
21
+ # @return [Regexp]
22
+ INITIAL_UNDERSCORE = /^_/
23
+
24
+ class << self
25
+ # Builds an uppercased ENV variable name from a Ruby namespace.
26
+ #
27
+ # @param opts [Hash]
28
+ # @option opts [String] :namespace (required) the Ruby namespace (e.g., "My::Lib")
29
+ # @option opts [String] :prefix optional ENV name prefix (defaults to DEFAULT_PREFIX)
30
+ # @return [String] the resulting ENV variable name
31
+ # @raise [FlossFunding::Error] when :namespace is not a String or contains invalid characters
32
+ def env_variable_name(opts = {})
33
+ namespace = opts[:namespace]
34
+ prefix = opts[:prefix] || DEFAULT_PREFIX
35
+ raise FlossFunding::Error, "namespace must be a String, but is #{namespace.class}" unless namespace.is_a?(String)
36
+
37
+ name_parts = namespace.split("::")
38
+ env_name = name_parts.map { |np| to_under_bar(np) }.join("_")
39
+ "#{prefix}#{env_name}".upcase
40
+ end
41
+
42
+ # Converts a single namespace segment to an underscored, uppercased string.
43
+ #
44
+ # @param string [String] the namespace segment to convert
45
+ # @return [String] an uppercased, underscore-separated representation
46
+ # @raise [FlossFunding::Error] when the string contains invalid characters or is too long
47
+ def to_under_bar(string)
48
+ safe = string[SAFE_TO_UNDERSCORE]
49
+ raise FlossFunding::Error, "Invalid! Each part of klass name must match #{SAFE_TO_UNDERSCORE}: #{safe} (#{safe.class}) != #{string[0..255]} (#{string.class})" unless safe == string.to_s
50
+
51
+ underscored = safe.gsub(SUBBER_UNDER) { "_#{$1}" }
52
+ shifted_leading_underscore = underscored.sub(INITIAL_UNDERSCORE, "")
53
+ shifted_leading_underscore.upcase
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlossFunding
4
+ # Version information for the FlossFunding gem.
5
+ module Version
6
+ # The current gem version.
7
+ # @return [String]
8
+ VERSION = "1.0.0-alpha.1"
9
+ end
10
+ end