toggles 0.1.1 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
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