toggles 0.1.1 → 0.4.0

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.
Files changed (48) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/ci.yml +24 -0
  3. data/CHANGELOG.md +83 -0
  4. data/Gemfile +5 -0
  5. data/LICENSE.txt +13 -0
  6. data/README.md +76 -0
  7. data/features/abbreviations_cn22.yml +3 -0
  8. data/features/file_s3.yml +3 -0
  9. data/features/nested_foo/bar_baz.yml +3 -0
  10. data/features/s3_file.yml +3 -0
  11. data/lib/toggles/constant_lookup.rb +50 -0
  12. data/lib/toggles/feature/attribute.rb +15 -0
  13. data/lib/toggles/feature/operation/and.rb +0 -15
  14. data/lib/toggles/feature/operation/in.rb +1 -1
  15. data/lib/toggles/feature/operation/range.rb +0 -2
  16. data/lib/toggles/feature/permissions.rb +34 -29
  17. data/lib/toggles/feature.rb +80 -12
  18. data/lib/toggles/version.rb +5 -0
  19. data/lib/toggles.rb +39 -38
  20. data/spec/spec_helper.rb +8 -3
  21. data/spec/toggles/feature/acceptance/collection_spec.rb +1 -1
  22. data/spec/toggles/feature/acceptance/complex_and_spec.rb +1 -1
  23. data/spec/toggles/feature/acceptance/multiple_subjects_spec.rb +1 -1
  24. data/spec/toggles/feature/acceptance/nested_attributes_spec.rb +1 -1
  25. data/spec/toggles/feature/acceptance/or_attributes_spec.rb +1 -1
  26. data/spec/toggles/feature/acceptance/type_spec.rb +20 -4
  27. data/spec/toggles/feature/attribute_spec.rb +9 -0
  28. data/spec/toggles/feature/operation_spec.rb +69 -0
  29. data/spec/toggles/feature/permissions_spec.rb +1 -1
  30. data/spec/toggles/feature_spec.rb +37 -0
  31. data/spec/toggles/init_spec.rb +89 -0
  32. data/toggles.gemspec +25 -15
  33. metadata +39 -67
  34. data/lib/toggles/feature/base.rb +0 -27
  35. data/lib/toggles/feature/operation/attribute.rb +0 -19
  36. data/lib/toggles/feature/operation/lt.rb +0 -9
  37. data/lib/toggles/feature/operation/not.rb +0 -15
  38. data/lib/toggles/feature/operation/or.rb +0 -15
  39. data/lib/toggles/feature/operation.rb +0 -8
  40. data/spec/toggles/feature/base_spec.rb +0 -10
  41. data/spec/toggles/feature/operation/and_spec.rb +0 -9
  42. data/spec/toggles/feature/operation/attribute_spec.rb +0 -9
  43. data/spec/toggles/feature/operation/gt_spec.rb +0 -6
  44. data/spec/toggles/feature/operation/in_spec.rb +0 -7
  45. data/spec/toggles/feature/operation/lt_spec.rb +0 -6
  46. data/spec/toggles/feature/operation/not_spec.rb +0 -6
  47. data/spec/toggles/feature/operation/or_spec.rb +0 -6
  48. data/spec/toggles/feature/operation/range_spec.rb +0 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 4c0bf3043c4b85c32062fa22750cc20c88af4cd4
4
- data.tar.gz: f8aeb5d080385e546815ccc57d8401e8abc1c9e4
2
+ SHA256:
3
+ metadata.gz: ece49487f49d27e3a10944d318e3b2c1b9a0e9a8a3c231581ebf5341e7abc4f2
4
+ data.tar.gz: d278a07f9e9e5e379936e02fa27e4a4cd7270ef2e7933181e5948c30d88bb4fd
5
5
  SHA512:
6
- metadata.gz: 2b7f5e471ba5456ee1ecc659f902b2826fecd2de532c1a4aef9b49e4791985df6cbdf73260e9643cd340711c3ab8f63c6daefb2691bcbc761fade903d3eacf93
7
- data.tar.gz: 08d1d0b300525bde3f0166b3fced8bc426b11f383eeb0ef4a6a60a1530e4e52f1e67522ea96472f2546a4f98d35ffece5642b3997569fb00777639626e9b14e0
6
+ metadata.gz: 2e80d24bb41c699217fa865e8ac2c346c4fbdc411d93eec98dc0d4ff11953f1c757a778606d62de664602a4320788b63389beca5f831431d7bc90bbee24eea0d
7
+ data.tar.gz: 4c4f3ea7f4d06f1a465f53e3114e5a456eed9c7de889e940910a01ed300823852e2b2cb823c89152685ed489800cf406d51efc876179feda4ad204077305ba72
@@ -0,0 +1,24 @@
1
+ name: 'CI'
2
+
3
+ on:
4
+ push:
5
+ branches: [ master ]
6
+ pull_request: ~
7
+ schedule:
8
+ - cron: "0 16 * * 1"
9
+
10
+ jobs:
11
+ run-tests:
12
+ runs-on: ubuntu-latest
13
+ strategy:
14
+ matrix:
15
+ rubyversion: ['2.7', '3.0', '3.1']
16
+ steps:
17
+ - uses: actions/checkout@v2
18
+ - name: set up ruby
19
+ uses: ruby/setup-ruby@v1
20
+ with:
21
+ ruby-version: ${{ matrix.rubyversion }}
22
+ bundler-cache: true
23
+ - name: run tests
24
+ run: bundle exec rspec
data/CHANGELOG.md ADDED
@@ -0,0 +1,83 @@
1
+ # Changelog
2
+
3
+ ## [v0.4.0](https://github.com/EasyPost/toggles/tree/v0.4.0)
4
+
5
+ [Full Changelog](https://github.com/EasyPost/toggles/compare/v0.3.0...v0.4.0)
6
+
7
+ **Merged pull requests:**
8
+
9
+ - refactor: remove Feature::Base, just use Feature::Permissions [\#13](https://github.com/EasyPost/toggles/pull/13) ([lanej](https://github.com/lanej))
10
+ - refactor: collapse some hierarchies, model operations as functions [\#12](https://github.com/EasyPost/toggles/pull/12) ([lanej](https://github.com/lanej))
11
+ - fix: handle abbreviations and numbers in feature name [\#11](https://github.com/EasyPost/toggles/pull/11) ([lanej](https://github.com/lanej))
12
+ - Deprecate constant lookups via .const\_missing [\#10](https://github.com/EasyPost/toggles/pull/10) ([lanej](https://github.com/lanej))
13
+
14
+ ## [v0.3.0](https://github.com/EasyPost/toggles/tree/v0.3.0) (2021-08-16)
15
+
16
+ [Full Changelog](https://github.com/EasyPost/toggles/compare/v0.1.2...v0.3.0)
17
+
18
+ **Merged pull requests:**
19
+
20
+ - add github actions integration [\#9](https://github.com/EasyPost/toggles/pull/9) ([Roguelazer](https://github.com/Roguelazer))
21
+ - point build status link at travis-ci.com instead of travis-ci.org [\#8](https://github.com/EasyPost/toggles/pull/8) ([Roguelazer](https://github.com/Roguelazer))
22
+ - fix tests [\#7](https://github.com/EasyPost/toggles/pull/7) ([Roguelazer](https://github.com/Roguelazer))
23
+ - run tests on travis [\#6](https://github.com/EasyPost/toggles/pull/6) ([Roguelazer](https://github.com/Roguelazer))
24
+
25
+ ## [v0.1.2](https://github.com/EasyPost/toggles/tree/v0.1.2) (2016-11-08)
26
+
27
+ [Full Changelog](https://github.com/EasyPost/toggles/compare/v0.1.1...v0.1.2)
28
+
29
+ **Merged pull requests:**
30
+
31
+ - store the actual feature configuration in a variable instead of in real constants under the Feature module [\#4](https://github.com/EasyPost/toggles/pull/4) ([Roguelazer](https://github.com/Roguelazer))
32
+
33
+ ## [v0.1.1](https://github.com/EasyPost/toggles/tree/v0.1.1) (2015-08-24)
34
+
35
+ [Full Changelog](https://github.com/EasyPost/toggles/compare/v0.1.0...v0.1.1)
36
+
37
+ ## [v0.1.0](https://github.com/EasyPost/toggles/tree/v0.1.0) (2015-08-24)
38
+
39
+ [Full Changelog](https://github.com/EasyPost/toggles/compare/v0.0.8...v0.1.0)
40
+
41
+ **Closed issues:**
42
+
43
+ - Handle when properties are nil [\#2](https://github.com/EasyPost/toggles/issues/2)
44
+
45
+ ## [v0.0.8](https://github.com/EasyPost/toggles/tree/v0.0.8) (2015-02-25)
46
+
47
+ [Full Changelog](https://github.com/EasyPost/toggles/compare/v0.0.7...v0.0.8)
48
+
49
+ **Merged pull requests:**
50
+
51
+ - Features automatically disabled\_for nil input [\#3](https://github.com/EasyPost/toggles/pull/3) ([victoryftw](https://github.com/victoryftw))
52
+
53
+ ## [v0.0.7](https://github.com/EasyPost/toggles/tree/v0.0.7) (2014-12-24)
54
+
55
+ [Full Changelog](https://github.com/EasyPost/toggles/compare/v0.0.6...v0.0.7)
56
+
57
+ **Closed issues:**
58
+
59
+ - Allow Fixnums etc. as top-level subjects [\#1](https://github.com/EasyPost/toggles/issues/1)
60
+
61
+ ## [v0.0.6](https://github.com/EasyPost/toggles/tree/v0.0.6) (2014-12-22)
62
+
63
+ [Full Changelog](https://github.com/EasyPost/toggles/compare/v0.0.5...v0.0.6)
64
+
65
+ ## [v0.0.5](https://github.com/EasyPost/toggles/tree/v0.0.5) (2014-12-17)
66
+
67
+ [Full Changelog](https://github.com/EasyPost/toggles/compare/v0.0.4...v0.0.5)
68
+
69
+ ## [v0.0.4](https://github.com/EasyPost/toggles/tree/v0.0.4) (2014-12-10)
70
+
71
+ [Full Changelog](https://github.com/EasyPost/toggles/compare/v0.0.3...v0.0.4)
72
+
73
+ ## [v0.0.3](https://github.com/EasyPost/toggles/tree/v0.0.3) (2014-12-10)
74
+
75
+ [Full Changelog](https://github.com/EasyPost/toggles/compare/v0.0.2...v0.0.3)
76
+
77
+ ## [v0.0.2](https://github.com/EasyPost/toggles/tree/v0.0.2) (2014-12-10)
78
+
79
+ [Full Changelog](https://github.com/EasyPost/toggles/compare/b19564d6ef5eb5d9c3b44d4eafe2fbac9d8a8938...v0.0.2)
80
+
81
+
82
+
83
+ \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*
data/Gemfile CHANGED
@@ -1,3 +1,8 @@
1
1
  source 'https://rubygems.org'
2
2
 
3
3
  gemspec
4
+
5
+ group :test do
6
+ gem "pry"
7
+ gem "pry-nav"
8
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,13 @@
1
+ Copyright (c) 2016-2016, Simpler Postage, Inc. <oss@easypost.com>
2
+
3
+ Permission to use, copy, modify, and/or distribute this software for any
4
+ purpose with or without fee is hereby granted, provided that the above
5
+ copyright notice and this permission notice appear in all copies.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
8
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
9
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
10
+ SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
11
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
12
+ OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
13
+ CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # Toggles
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/toggles.svg)](https://badge.fury.io/rb/toggles)
4
+ [![CI](https://github.com/EasyPost/toggles/workflows/CI/badge.svg)](https://github.com/EasyPost/toggles/actions?query=workflow%3ACI)
5
+
6
+ YAML backed feature toggles
7
+
8
+ ## Installation
9
+
10
+ Add the following to your Gemfile:
11
+
12
+ ```ruby
13
+ gem "toggles"
14
+ ```
15
+
16
+ and run `bundle install` from your shell.
17
+
18
+ To install the gem manually from your shell, run:
19
+
20
+ ```ruby
21
+ gem install toggles
22
+ ```
23
+
24
+ ## Configuration
25
+
26
+ Configure `toggles`:
27
+
28
+ ```ruby
29
+ Toggles.configure do |config|
30
+ config.features_dir = "features"
31
+ end
32
+ ```
33
+
34
+ You can now express conditional logic within `features_dir`. The structure of the `features_dir` determines the structure of the classes within the `Feature` module. For example if the `features_dir` has the structure:
35
+
36
+ ```
37
+ features
38
+ ├── thing
39
+ | ├── one.yml
40
+ | └── two.yml
41
+ └── test.yml
42
+ ```
43
+
44
+ You can call the `Toggles.init` method to force re-parsing the configuration and re-initializing all Features
45
+ structures at any time. The `Toggles.reinit_if_necessary` method is a convenience helper which will only
46
+ re-initialize of the top-level features directory has changed. Note that, in general, this will only detect
47
+ changes if you use a system where you swap out the entire features directory on changes and do not edit
48
+ individual files within the directory.
49
+
50
+ ## Usage
51
+
52
+ Create a file in `features_dir`:
53
+
54
+ ```yaml
55
+ user:
56
+ id:
57
+ in:
58
+ - 12345
59
+ - 54321
60
+ ```
61
+
62
+ Check if the feature is enabled or disabled:
63
+
64
+ ```ruby
65
+ Feature.enabled?(:new_feature, :available_for_presentation, user: OpenStruct.new(id: 12345)) # true
66
+ Feature.enabled?(:new_feature, :available_for_presentation, user: OpenStruct.new(id: 54321)) # true
67
+ Feature.enabled?(:new_feature, :available_for_presentation, user: OpenStruct.new(id: 7)) # false
68
+
69
+ Feature.disabled?(:new_feature, :available_for_presentation, user: OpenStruct.new(id: 12345)) # false
70
+ Feature.disabled?(:new_feature, :available_for_presentation, user: OpenStruct.new(id: 54321)) # false
71
+ Feature.disabled?(:new_feature, :available_for_presentation, user: OpenStruct.new(id: 7)) # true
72
+ ```
73
+
74
+ ## License
75
+
76
+ This project is licensed under the ISC License, the contents of which can be found at [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,3 @@
1
+ ---
2
+ user:
3
+ id: 1
@@ -0,0 +1,3 @@
1
+ ---
2
+ user:
3
+ id: 1
@@ -0,0 +1,3 @@
1
+ id:
2
+ lt:
3
+ 5
@@ -0,0 +1,3 @@
1
+ ---
2
+ user:
3
+ id: 1
@@ -0,0 +1,50 @@
1
+ class Feature::ConstantLookup
2
+ Error = Class.new(NameError) do
3
+ attr_reader :sym
4
+
5
+ def initialize(sym)
6
+ @sym = sym
7
+ super(sym.join('::'))
8
+ end
9
+ end
10
+
11
+ # Return a tree walker that translates Module#const_missing(sym) into the next child node
12
+ #
13
+ # So Features::Cat::BearDog walks as:
14
+ # * next_features = Feature.features # root
15
+ # * const_missing(:Cat) => next_features = next_features['cat']
16
+ # * const_missing(:BearDog) => next_features['bear_dog']
17
+ # * const_missing(:CN22) => next_features['c_n_22']
18
+ #
19
+ # Defined at Toggles.features_dir + "/cat/bear_dog.yaml"
20
+ #
21
+ # @raise [Error] if constant cannot be resolved
22
+ def self.from(features, path)
23
+ Class.new {
24
+ class << self
25
+ attr_accessor :features, :path
26
+
27
+ def const_missing(sym)
28
+ # translate class name into path part i.e :BearDog #=> 'bear_dog'
29
+ key = sym.to_s
30
+ key.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2'.freeze)
31
+ key.gsub!(/([a-z\d])([A-Z])/, '\1_\2'.freeze)
32
+ key.downcase!
33
+
34
+ subtree_or_feature = features.fetch(key.to_sym)
35
+
36
+ if subtree_or_feature.is_a?(Hash)
37
+ Feature::ConstantLookup.from(subtree_or_feature, path + [sym])
38
+ else
39
+ subtree_or_feature
40
+ end
41
+ rescue KeyError
42
+ raise Error, path + [sym]
43
+ end
44
+ end
45
+ }.tap do |resolver|
46
+ resolver.features = features
47
+ resolver.path = path
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,15 @@
1
+ class Feature::Attribute
2
+ def self.call(entity, attr_name, expected)
3
+ if expected.kind_of? Hash
4
+ expected.all? do |operation, rules|
5
+ if Feature.operations.include? operation.to_sym
6
+ Feature.operations[operation.to_sym].call(entity, attr_name, rules)
7
+ else
8
+ call(entity.send(attr_name), operation, rules)
9
+ end
10
+ end
11
+ else
12
+ entity.send(attr_name) == expected
13
+ end
14
+ end
15
+ end
@@ -1,15 +0,0 @@
1
- module Feature
2
- module Operation
3
- class And
4
- def self.call(entity, attr_name, expected)
5
- expected.all? do |operation, value|
6
- if OPERATIONS.include? operation.to_sym
7
- OPERATIONS[operation.to_sym].call(entity, attr_name, value)
8
- else
9
- Operation::Attribute.call(entity, operation, value)
10
- end
11
- end
12
- end
13
- end
14
- end
15
- end
@@ -4,7 +4,7 @@ module Feature
4
4
  def self.call(entity, attr_name, expected)
5
5
  if expected.kind_of? Hash
6
6
  expected = expected.reduce([]) do |list, (operation, args)|
7
- OPERATIONS[operation.to_sym].call(args)
7
+ Feature.operations[operation.to_sym].call(args)
8
8
  end
9
9
  end
10
10
 
@@ -2,8 +2,6 @@ module Feature
2
2
  module Operation
3
3
  class Range
4
4
  def self.call(args)
5
- raise StandardError, "Invalid range operation" if args.size != 2
6
- (args.first..args.last)
7
5
  end
8
6
  end
9
7
  end
@@ -1,43 +1,48 @@
1
- require "ostruct"
2
- require "forwardable"
1
+ require 'ostruct'
2
+ require 'forwardable'
3
+ require 'yaml'
3
4
 
4
- module Feature
5
- class Permissions
6
- extend Forwardable
5
+ Feature::Permissions = Struct.new(:rules) do
6
+ extend Forwardable
7
7
 
8
- attr_reader :rules
8
+ def_delegators :rules, :all?, :keys
9
9
 
10
- def_delegators :rules, :all?, :keys
10
+ def self.from_yaml(path)
11
+ new(
12
+ YAML.safe_load(File.read(path), permitted_classes: [Symbol])
13
+ )
14
+ end
11
15
 
12
- def initialize(path)
13
- @rules = YAML.load(File.read(path))
14
- end
16
+ def subjects
17
+ @subjects ||= keys.map(&:to_sym)
18
+ end
15
19
 
16
- def subjects
17
- @subjects ||= keys.map(&:to_sym)
20
+ def valid_for?(entities)
21
+ unless subjects == entities.keys
22
+ raise Feature::Subject::Invalid, Feature::Subject.difference(subjects, entities.keys)
18
23
  end
19
24
 
20
- def valid_for?(entities)
21
- unless subjects == entities.keys
22
- raise Subject::Invalid, Subject.difference(subjects, entities.keys)
23
- end
24
-
25
- rules.all? do |name, rule|
26
- entity = entities[name.to_sym]
25
+ rules.all? do |name, rule|
26
+ entity = entities[name.to_sym]
27
27
 
28
- if entity.nil?
29
- return false
30
- end
28
+ return false if entity.nil?
31
29
 
32
- if entity.class.ancestors.find { |ancestor| ancestor == Comparable }
33
- entity = OpenStruct.new(name => entity)
34
- rule = {name => rule}
35
- end
30
+ if entity.class.ancestors.find { |ancestor| ancestor == Comparable }
31
+ entity = OpenStruct.new(name => entity)
32
+ rule = { name => rule }
33
+ end
36
34
 
37
- rule.all? do |key, value|
38
- OPERATIONS.fetch(key.to_sym, Operation::Attribute).call(entity, key, value)
39
- end
35
+ rule.all? do |key, value|
36
+ Feature.operations.fetch(key.to_sym, Feature::Attribute).call(entity, key, value)
40
37
  end
41
38
  end
42
39
  end
40
+
41
+ def enabled_for?(subjects = {})
42
+ valid_for?(subjects)
43
+ end
44
+
45
+ def disabled_for?(subjects = {})
46
+ !valid_for?(subjects)
47
+ end
43
48
  end
@@ -1,16 +1,84 @@
1
- require "find"
1
+ # frozen_string_literal: true
2
+
3
+ module Feature
4
+ Error = Class.new(StandardError)
5
+ Unknown = Class.new(Error)
6
+
7
+ def self.features
8
+ @features ||= {}
9
+ end
10
+
11
+ def self.operations
12
+ @operations ||= {}
13
+ end
14
+
15
+ # @deprecated This is an abuse of lazy dispatch that creates cryptic errors
16
+ def self.const_missing(sym)
17
+ ConstantLookup.from(features, [:Feature]).const_missing(sym)
18
+ end
2
19
 
3
- require "toggles/feature/base"
4
- require "toggles/feature/operation"
20
+ def self.enabled?(*sym, **criteria)
21
+ sym
22
+ .inject(features) { |a, e| a.fetch(e) }
23
+ .enabled_for?(criteria)
24
+ rescue KeyError
25
+ raise Unknown, sym.inspect
26
+ end
27
+
28
+ def self.disabled?(*sym, **criteria)
29
+ !enabled?(*sym, **criteria)
30
+ end
31
+ end
32
+
33
+ require 'toggles/constant_lookup'
34
+ require "toggles/feature/attribute"
5
35
  require "toggles/feature/permissions"
6
36
  require "toggles/feature/subject"
7
37
 
8
- module Feature
9
- OPERATIONS = {and: Operation::And,
10
- gt: Operation::GreaterThan,
11
- in: Operation::In,
12
- lt: Operation::LessThan,
13
- not: Operation::Not,
14
- or: Operation::Or,
15
- range: Operation::Range}
16
- end
38
+ Feature.operations[:and] = lambda { |entity, attr_name, expected|
39
+ expected.all? do |operation, value|
40
+ if Feature.operations.include? operation.to_sym
41
+ Feature.operations[operation.to_sym].call(entity, attr_name, value)
42
+ else
43
+ Feature::Attribute.call(entity, operation, value)
44
+ end
45
+ end
46
+ }
47
+
48
+ Feature.operations[:in] = lambda { |entity, attr_name, expected|
49
+ if expected.is_a? Hash
50
+ expected = expected.reduce([]) do |_list, (operation, args)|
51
+ Feature.operations[operation.to_sym].call(args)
52
+ end
53
+ end
54
+
55
+ expected.include? entity.send(attr_name.to_sym)
56
+ }
57
+
58
+ Feature.operations[:not] = lambda { |entity, attr_name, expected|
59
+ if expected.is_a? Hash
60
+ expected.none? do |operation, value|
61
+ Feature.operations[operation.to_sym].call(entity, attr_name, value)
62
+ end
63
+ else
64
+ entity.send(attr_name) != expected
65
+ end
66
+ }
67
+
68
+ Feature.operations[:or] = lambda { |entity, attr_name, expected|
69
+ expected.any? do |operation, value|
70
+ if Feature.operations.include? operation.to_sym
71
+ Feature.operations[operation.to_sym].call(entity, attr_name, value)
72
+ else
73
+ Feature::Attribute.call(entity, operation, value)
74
+ end
75
+ end
76
+ }
77
+
78
+ Feature.operations[:gt] = ->(entity, attr_name, expected) { entity.send(attr_name) > expected }
79
+ Feature.operations[:lt] = ->(entity, attr_name, expected) { entity.send(attr_name) < expected }
80
+ Feature.operations[:range] = lambda { |args|
81
+ raise StandardError, 'Invalid range operation' if args.size != 2
82
+
83
+ (args.first..args.last)
84
+ }
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggles
4
+ VERSION = '0.4.0'
5
+ end
data/lib/toggles.rb CHANGED
@@ -1,10 +1,19 @@
1
- require "toggles/configuration"
2
- require "toggles/feature"
1
+ require 'find'
2
+ require 'pathname'
3
+ require 'set'
4
+
5
+ require 'toggles/version'
6
+
7
+ require 'toggles/configuration'
8
+ require 'toggles/feature'
3
9
 
4
10
  module Toggles
5
11
  extend self
6
12
 
13
+ StatResult = Struct.new(:inode, :mtime)
14
+
7
15
  def configure
16
+ @stat_tuple ||= StatResult.new(0, 0)
8
17
  yield configuration
9
18
  init
10
19
  end
@@ -13,43 +22,35 @@ module Toggles
13
22
  @configuration ||= Configuration.new
14
23
  end
15
24
 
16
- # Dynamically create modules and classes within the `Feature` module based on
17
- # the directory structure of `features`.
18
- #
19
- # For example if the `features` directory has the structure:
20
- #
21
- # features
22
- # ├── thing
23
- # | ├── one.yml
24
- # | └── two.yml
25
- # └── test.yml
26
- #
27
- # `Feature::Test`, `Feature::Thing::One`, `Feature::Thing::Two` would be
28
- # available by default.
29
- #
30
25
  def init
31
- return unless Dir.exists? configuration.features_dir
32
-
33
- Find.find(configuration.features_dir) do |path|
34
- if path.match(/\.ya?ml\Z/)
35
- _, *directories, filename = path.chomp(File.extname(path)).split("/")
36
-
37
- previous = Feature
38
- directories.each do |directory|
39
- module_name = directory.split("_").map(&:capitalize).join.to_sym
40
- previous = if previous.constants.include? module_name
41
- previous.const_get(module_name)
42
- else
43
- previous.const_set(module_name, Module.new)
44
- end
45
- end
46
-
47
- cls = Class.new(Feature::Base) do |c|
48
- c.const_set(:PERMISSIONS, Feature::Permissions.new(path))
49
- end
50
-
51
- previous.const_set(filename.split("_").map(&:capitalize).join.to_sym, cls)
52
- end
26
+ return unless Dir.exist? configuration.features_dir
27
+
28
+ top_level = File.realpath(configuration.features_dir)
29
+ top_level_p = Pathname.new(top_level)
30
+
31
+ Feature.features.clear
32
+
33
+ Dir[File.join(top_level, '**/*.{yaml,yml}')].each do |abspath|
34
+ path = Pathname.new(abspath).relative_path_from(top_level_p).to_s
35
+ features = path.split('/')[0..-2].inject(Feature.features) { |a, e| a[e.to_sym] ||= {} }
36
+ feature_key = File.basename(path, File.extname(path)).to_sym
37
+ features[feature_key] = Feature::Permissions.from_yaml(abspath)
53
38
  end
39
+
40
+ stbuf = File.stat(top_level)
41
+ @stat_tuple = StatResult.new(stbuf.ino, stbuf.mtime)
42
+ end
43
+
44
+ def reinit_if_changed
45
+ # Reload the configuration if the top-level directory has changed.
46
+ # Does not detect changes to files inside that directory unless your
47
+ # filesystem propagates mtimes.
48
+ return unless Dir.exist? configuration.features_dir
49
+
50
+ top_level = File.realpath(configuration.features_dir)
51
+ stbuf = File.stat(top_level)
52
+ stat_tuple = StatResult.new(stbuf.ino, stbuf.mtime)
53
+
54
+ init if @stat_tuple != stat_tuple
54
55
  end
55
56
  end
data/spec/spec_helper.rb CHANGED
@@ -1,11 +1,16 @@
1
1
  require "rspec/its"
2
+ require 'rspec/temp_dir'
2
3
 
3
4
  require "toggles"
4
5
 
5
- Toggles.configure do |config|
6
- config.features_dir = "features"
7
- end
6
+ Bundler.require(:test)
8
7
 
9
8
  RSpec.configure do |config|
10
9
  config.order = "random"
10
+
11
+ config.before(:each) do
12
+ Toggles.configure do |c|
13
+ c.features_dir = "features"
14
+ end
15
+ end
11
16
  end
@@ -1,4 +1,4 @@
1
- describe Feature::Collection do
1
+ describe "Feature::Collection" do
2
2
  specify do
3
3
  expect(Feature::Collection.enabled_for?(user: double(id: 1))).to eq true
4
4
  expect(Feature::Collection.enabled_for?(user: double(id: 5))).to eq true
@@ -1,4 +1,4 @@
1
- describe Feature::ComplexAnd do
1
+ describe "Feature::ComplexAnd" do
2
2
  specify do
3
3
  expect(Feature::ComplexAnd.enabled_for?(data: double(id: 1))).to eq true
4
4
  expect(Feature::ComplexAnd.enabled_for?(data: double(id: 2, timestamp: 0))).to eq true
@@ -1,4 +1,4 @@
1
- describe Feature::MultipleSubjects do
1
+ describe "Feature::MultipleSubjects" do
2
2
  specify do
3
3
  expect(Feature::MultipleSubjects.enabled_for?(
4
4
  user: double(id: 1, logged_in?: true), widget: double(id: 2))).to eq true
@@ -1,4 +1,4 @@
1
- describe Feature::NestedAttributes do
1
+ describe "Feature::NestedAttributes" do
2
2
  specify do
3
3
  expect(Feature::NestedAttributes.enabled_for?(
4
4
  foo: double(bar: :two, baz: double(id: 51)))).to eq true