From 748b5ad4edc1447d2ba3fb5b73bd5b920ee468e3 Mon Sep 17 00:00:00 2001 From: Burke Libbey Date: Mon, 22 Jan 2018 15:21:45 -0500 Subject: [PATCH] Extract various components from dev --- Gemfile.lock | 8 +- lib/cli/kit.rb | 53 ++++++- lib/cli/kit/base_command.rb | 43 ++++++ lib/cli/kit/command_registry.rb | 79 ++++++++++ lib/cli/kit/config.rb | 102 +++++++++++++ lib/cli/kit/entry_point.rb | 141 ++++++++++++++++++ lib/cli/kit/ini.rb | 93 ++++++++++++ lib/cli/kit/levenshtein.rb | 82 ++++++++++ lib/cli/kit/report_errors.rb | 61 ++++++++ lib/cli/kit/version.rb | 2 +- test/cli/kit/base_command_test.rb | 117 +++++++++++++++ test/cli/kit/config_test.rb | 69 +++++++++ .../ini_with_and_without_heading.conf | 8 + test/fixtures/ini_with_heading.conf | 3 + test/fixtures/ini_with_types.conf | 3 + test/fixtures/ini_without_heading.conf | 2 + 16 files changed, 860 insertions(+), 6 deletions(-) create mode 100644 lib/cli/kit/base_command.rb create mode 100644 lib/cli/kit/command_registry.rb create mode 100644 lib/cli/kit/config.rb create mode 100644 lib/cli/kit/entry_point.rb create mode 100644 lib/cli/kit/ini.rb create mode 100644 lib/cli/kit/levenshtein.rb create mode 100644 lib/cli/kit/report_errors.rb create mode 100644 test/cli/kit/base_command_test.rb create mode 100644 test/cli/kit/config_test.rb create mode 100644 test/fixtures/ini_with_and_without_heading.conf create mode 100644 test/fixtures/ini_with_heading.conf create mode 100644 test/fixtures/ini_with_types.conf create mode 100644 test/fixtures/ini_without_heading.conf diff --git a/Gemfile.lock b/Gemfile.lock index df7614b..bb45aec 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,8 +1,8 @@ PATH remote: . specs: - dev-kit (0.1.0) - dev-ui (>= 0.1.0) + cli-kit (2.0.0) + cli-ui (>= 1.0.0) GEM remote: https://rubygems.org/ @@ -11,7 +11,7 @@ GEM ast (2.3.0) builder (3.2.3) byebug (9.0.6) - dev-ui (0.1.0) + cli-ui (1.0.0) metaclass (0.0.4) method_source (0.8.2) minitest (5.10.2) @@ -45,7 +45,7 @@ PLATFORMS DEPENDENCIES bundler (~> 1.15) byebug - dev-kit! + cli-kit! method_source minitest (>= 5.0.0) minitest-reporters diff --git a/lib/cli/kit.rb b/lib/cli/kit.rb index 499c797..c2f7c40 100644 --- a/lib/cli/kit.rb +++ b/lib/cli/kit.rb @@ -2,6 +2,57 @@ module CLI module Kit - autoload :System, 'cli/kit/system' + class << self + attr_accessor :tool_name + end + + autoload :BaseCommand, 'cli/kit/base_command' + autoload :CommandRegistry, 'cli/kit/command_registry' + autoload :Config, 'cli/kit/config' + autoload :EntryPoint, 'cli/kit/entry_point' + autoload :Ini, 'cli/kit/ini' + autoload :Levenshtein, 'cli/kit/levenshtein' + autoload :ReportErrors, 'cli/kit/report_errors' + autoload :System, 'cli/kit/system' + + EXIT_FAILURE_BUT_NOT_BUG = 30 + EXIT_BUG = 1 + EXIT_SUCCESS = 0 + + # Abort, Bug, AbortSilent, and BugSilent are four ways of immediately bailing + # on command-line execution when an unrecoverable error occurs. + # + # Note that these don't inherit from StandardError, and so are not caught by + # a bare `rescue => e`. + # + # * Abort prints its message in red and exits 1; + # * Bug additionally submits the exception to Bugsnag; + # * AbortSilent and BugSilent do the same as above, but do not print + # messages before exiting. + # + # Treat these like panic() in Go: + # * Don't rescue them. Use a different Exception class if you plan to recover; + # * Provide a useful message, since it will be presented in brief to the + # user, and will be useful for debugging. + # * Avoid using it if it does actually make sense to recover from an error. + # + # Additionally: + # * Do not subclass these. + # * Only use AbortSilent or BugSilent if you prefer to print a more + # contextualized error than Abort or Bug would present to the user. + # * In general, don't attach a message to AbortSilent or BugSilent. + # * Never raise GenericAbort directly. + # * Think carefully about whether Abort or Bug is more appropriate. Is this + # a bug in the tool? Or is it just user error, transient network + # failure, etc.? + # * One case where it's ok to rescue these outside of EntryPoint (or tests): + # 1. rescue Abort or Bug + # 2. Print a contextualized error message + # 3. Re-raise AbortSilent or BugSilent respectively. + GenericAbort = Class.new(Exception) + Abort = Class.new(GenericAbort) + Bug = Class.new(GenericAbort) + BugSilent = Class.new(GenericAbort) + AbortSilent = Class.new(GenericAbort) end end diff --git a/lib/cli/kit/base_command.rb b/lib/cli/kit/base_command.rb new file mode 100644 index 0000000..39f5276 --- /dev/null +++ b/lib/cli/kit/base_command.rb @@ -0,0 +1,43 @@ +require 'cli/kit' + +module CLI + module Kit + class BaseCommand + def self.defined? + true + end + + def self.statsd_increment(metric, **kwargs) + nil + end + + def self.statsd_time(metric, **kwargs) + yield + end + + def self.call(args, command_name) + cmd = new + stats_tags = ["task:#{cmd.class}"] + stats_tags << "subcommand:#{args.first}" if args && args.first && cmd.has_subcommands? + begin + statsd_increment("cli.command.invoked", tags: stats_tags) + statsd_time("cli.command.time", tags: stats_tags) do + cmd.call(args, command_name) + end + statsd_increment("cli.command.success", tags: stats_tags) + rescue => e + statsd_increment("cli.command.exception", tags: stats_tags + ["exception:#{e.class}"]) + raise e + end + end + + def call(args, command_name) + raise NotImplementedError + end + + def has_subcommands? + false + end + end + end +end diff --git a/lib/cli/kit/command_registry.rb b/lib/cli/kit/command_registry.rb new file mode 100644 index 0000000..542b320 --- /dev/null +++ b/lib/cli/kit/command_registry.rb @@ -0,0 +1,79 @@ +require 'cli/kit' + +module CLI + module Kit + module CommandRegistry + attr_accessor :commands, :aliases + class << self + attr_accessor :registry_target + end + + def resolve_contextual_command + nil + end + + def contextual_aliases + {} + end + + def contextual_command_class(_name) + raise NotImplementedError + end + + def self.extended(base) + raise "multiple registries unsupported" if self.registry_target + self.registry_target = base + base.commands = {} + base.aliases = {} + end + + def register(const, name, path) + autoload(const, path) + commands[name] = const + end + + def lookup_command(name) + return default_command if name.to_s == "" + resolve_command(name) + end + + def register_alias(from, to) + aliases[from] = to unless aliases[from] + end + + def resolve_command(name) + resolve_global_command(name) || \ + resolve_contextual_command(name) || \ + [nil, resolve_alias(name)] + end + + def resolve_alias(name) + aliases[name] || contextual_aliases.fetch(name, name) + end + + def resolve_global_command(name) + name = aliases.fetch(name, name) + command_class = const_get(commands.fetch(name, "")) + return nil unless command_class.defined? + [command_class, name] + rescue NameError + nil + end + + def resolve_contextual_command(name) + name = resolve_alias(name) + found = contextual_command_names.include?(name) + return nil unless found + [contextual_command_class(name), name] + end + + def command_names + contextual_command_names + commands.keys + end + + def exist?(name) + !resolve_command(name).first.nil? + end + end + end +end diff --git a/lib/cli/kit/config.rb b/lib/cli/kit/config.rb new file mode 100644 index 0000000..3bba1a0 --- /dev/null +++ b/lib/cli/kit/config.rb @@ -0,0 +1,102 @@ +require 'cli/kit' +require 'fileutils' + +module CLI + module Kit + class Config + XDG_CONFIG_HOME = 'XDG_CONFIG_HOME' + + # Returns the config corresponding to `name` from the config file + # `false` is returned if it doesn't exist + # + # #### Parameters + # `section` : the section of the config value you are looking for + # `name` : the name of the config value you are looking for + # + # #### Returns + # `value` : the value of the config variable (false if none) + # + # #### Example Usage + # `config.get('name.of.config')` + # + def get(section, name = nil) + section, name = section.split('.', 2) if name.nil? + # TODO: Remove this and all global configs + return get("global", section) if name.nil? + all_configs.dig("[#{section}]", name) || false + end + + # Sets the config value in the config file + # + # #### Parameters + # `section` : the section of the config you are setting + # `name` : the name of the config you are setting + # `value` : the value of the config you are setting + # + # #### Example Usage + # `config.set('section', 'name.of.config', 'value')` + # + def set(section, name = nil, value) + section, name = section.split('.', 2) if name.nil? + # TODO: Remove this and all global configs + return set("global", section, value) if name.nil? + all_configs["[#{section}]"] ||= {} + all_configs["[#{section}]"][name] = value.nil? ? nil : value.to_s + write_config + end + + def get_section(section) + (all_configs["[#{section}]"] || {}).dup + end + + # Returns a path from config in expanded form + # e.g. shopify corresponds to ~/src/shopify, but is expanded to /Users/name/src/shopify + # + # #### Example Usage + # `config.get_path('srcpath', 'shopify')` + # + # #### Returns + # `path` : the expanded path to the corrsponding value + # + def get_path(section, name = nil) + v = get(section, name) + false == v ? v : File.expand_path(v) + end + + def to_s + ini.to_s + end + + # The path on disk at which the configuration is stored: + # `$XDG_CONFIG_HOME//config` + # if ENV['XDG_CONFIG_HOME'] is not set, we default to ~/.config, e.g.: + # ~/.config/tool/config + # + def file + config_home = ENV.fetch(XDG_CONFIG_HOME, '~/.config') + File.expand_path(File.join(CLI::Kit.tool_name, 'config'), config_home) + end + + private + + def all_configs + ini.ini + end + + def ini + @ini ||= CLI::Kit::Ini + .new(file, default_section: "[global]", convert_types: false) + .tap(&:parse) + end + + def write_config + all_configs.each do |section, sub_config| + all_configs[section] = sub_config.reject { |_, value| value.nil? } + all_configs.delete(section) if all_configs[section].empty? + end + FileUtils.mkdir_p(File.dirname(file)) + File.write(file, to_s) + end + end + end +end diff --git a/lib/cli/kit/entry_point.rb b/lib/cli/kit/entry_point.rb new file mode 100644 index 0000000..05d5dab --- /dev/null +++ b/lib/cli/kit/entry_point.rb @@ -0,0 +1,141 @@ +require 'cli/kit' +require 'cli/ui' +require 'English' + +module CLI + module Kit + class EntryPoint + # Interface methods: You may want to implement these: + + def self.before_initialize(args) + nil + end + + def self.troubleshoot(e) + nil + end + + def self.log_file + nil + end + + # End Interface methods + + def self.call(args) + trap('QUIT') do + z = caller + CLI::UI.raw do + STDERR.puts('SIGQUIT: quit') + STDERR.puts(z) + end + exit 1 + end + trap('INFO') do + z = caller + CLI::UI.raw do + STDERR.puts('SIGINFO:') + STDERR.puts(z) + # Thread.list.map { |t| t.backtrace } + end + end + + before_initialize(args) + + new(args).call + end + + def initialize(args) + command_name = args.shift + @args = args + + ret = EntryPoint.handle_abort do + @command, @command_name = lookup_command(command_name) + :success + end + + if ret != :success + exit CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG + end + end + + def lookup_command(name) + CLI::Kit::CommandRegistry.registry_target.lookup_command(name) + end + + def self.format_error_message(msg) + CLI::UI.fmt("{{red:#{e.message}}}") + end + + def self.handle_abort + yield + rescue CLI::Kit::GenericAbort => e + is_bug = e.is_a?(CLI::Kit::Bug) || e.is_a?(CLI::Kit::BugSilent) + is_silent = e.is_a?(CLI::Kit::AbortSilent) || e.is_a?(CLI::Kit::BugSilent) + + if !is_silent && ENV['IM_ALREADY_PRO_THANKS'].nil? + troubleshoot(e) + elsif !is_silent + STDERR.puts(format_error_message(e.message)) + end + + if is_bug + CLI::Kit::ReportErrors.exception = e + end + + return CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG + rescue Interrupt + STDERR.puts(format_error_message("Interrupt")) + return CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG + end + + def with_logging(log_file, &block) + return yield unless log_file + CLI::UI.log_output_to(log_file, &block) + end + + def self.commands_and_aliases + reg = CLI::Kit::CommandRegistry.registry_target + reg.command_names + reg.aliases.keys + end + + def call + with_logging(EntryPoint.log_file) do + EntryPoint.handle_abort do + if @command.nil? + CLI::UI::Frame.open("Command not found", color: :red, timing: false) do + STDERR.puts(CLI::UI.fmt("{{command:#{CLI::Kit.tool_name} #{@command_name}}} was not found")) + end + + cmds = EntryPoint.commands_and_aliases + if cmds.all? { |cmd| cmd.is_a?(String) } + possible_matches = cmds.min_by(2) do |cmd| + CLI::Kit::Levenshtein.distance(cmd, @command_name) + end + + # We don't want to match against any possible command + # so reject anything that is too far away + possible_matches.reject! do |possible_match| + CLI::Kit::Levenshtein.distance(possible_match, @command_name) > 3 + end + + # If we have any matches left, tell the user + if possible_matches.any? + CLI::UI::Frame.open("{{bold:Did you mean?}}", timing: false, color: :blue) do + possible_matches.each do |possible_match| + STDERR.puts CLI::UI.fmt("{{command:#{CLI::Kit.tool_name} #{possible_match}}}") + end + end + end + end + + raise CLI::Kit::AbortSilent # Already output message + end + + @command.call(@args, @command_name) + CLI::Kit::EXIT_SUCCESS # unless an exception was raised + end + end + end + end + end +end diff --git a/lib/cli/kit/ini.rb b/lib/cli/kit/ini.rb new file mode 100644 index 0000000..e61e454 --- /dev/null +++ b/lib/cli/kit/ini.rb @@ -0,0 +1,93 @@ +module CLI + module Kit + # INI is a language similar to JSON or YAML, but simplied + # The spec is here: https://en.wikipedia.org/wiki/INI_file + # This parser includes supports for 2 very basic uses + # - Sections + # - Key Value Pairs (within and outside of the sections) + # + # [global] + # key = val + # + # Nothing else is supported right now + # See the ini_test.rb file for more examples + # + class Ini + attr_accessor :ini + + def initialize(path = nil, default_section: nil, convert_types: true) + @config = File.readlines(path) if path && File.exist?(path) + @ini = {} + @current_key = nil + @default_section = default_section + @convert_types = convert_types + end + + def parse + return @ini if @config.nil? + + @config.each do |l| + l.strip! + + # If section, then set current key, this will nest the setting + if section_designator?(l) + @current_key = l + + # A new line will reset the current key + elsif l.strip.empty? + @current_key = nil + + # Otherwise set the values + else + k, v = l.split('=').map(&:strip) + set_val(k, v) + end + end + @ini + end + + def to_s + to_ini(@ini).flatten.join("\n") + end + + private + + def to_ini(h) + str = [] + h.each do |k, v| + if section_designator?(k) + str << "" unless str.empty? + str << k + str << to_ini(v) + else + str << "#{k} = #{v}" + end + end + str + end + + def set_val(key, val) + return if key.nil? && val.nil? + + current_key = @current_key || @default_section + if current_key + @ini[current_key] ||= {} + @ini[current_key][key] = typed_val(val) + else + @ini[key] = typed_val(val) + end + end + + def typed_val(val) + return val.to_s unless @convert_types + return val.to_i if val =~ /^-?[0-9]+$/ + return val.to_f if val =~ /^-?[0-9]+\.[0-9]*$/ + val.to_s + end + + def section_designator?(k) + k.start_with?('[') && k.end_with?(']') + end + end + end +end diff --git a/lib/cli/kit/levenshtein.rb b/lib/cli/kit/levenshtein.rb new file mode 100644 index 0000000..bc50423 --- /dev/null +++ b/lib/cli/kit/levenshtein.rb @@ -0,0 +1,82 @@ +# Copyright (c) 2014-2016 Yuki Nishijima + +# MIT License + +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +module CLI + module Kit + module Levenshtein + # This code is based directly on the Text gem implementation + # Copyright (c) 2006-2013 Paul Battley, Michael Neumann, Tim Fletcher. + # + # Returns a value representing the "cost" of transforming str1 into str2 + def distance(str1, str2) + n = str1.length + m = str2.length + return m if n.zero? + return n if m.zero? + + d = (0..m).to_a + x = nil + + # to avoid duplicating an enumerable object, create it outside of the loop + str2_codepoints = str2.codepoints + + str1.each_codepoint.with_index(1) do |char1, i| + j = 0 + while j < m + cost = char1 == str2_codepoints[j] ? 0 : 1 + x = min3( + d[j + 1] + 1, # insertion + i + 1, # deletion + d[j] + cost # substitution + ) + d[j] = i + i = x + + j += 1 + end + d[m] = x + end + + x + end + module_function :distance + + private + + # detects the minimum value out of three arguments. This method is + # faster than `[a, b, c].min` and puts less GC pressure. + # See https://github.com/yuki24/did_you_mean/pull/1 for a performance + # benchmark. + def min3(a, b, c) + if a < b && a < c + a + elsif b < c + b + else + c + end + end + module_function :min3 + end + end +end diff --git a/lib/cli/kit/report_errors.rb b/lib/cli/kit/report_errors.rb new file mode 100644 index 0000000..28117dd --- /dev/null +++ b/lib/cli/kit/report_errors.rb @@ -0,0 +1,61 @@ +require 'English' + +module CLI + module Kit + module ReportErrors + class << self + attr_accessor :exception + end + + # error_reporter should support the interface: + # .call( + # notify_with, :: Exception + # logs, :: String (stdout+stderr of process before crash) + # ) + def self.setup(logfile_path, error_reporter) + at_exit do + CLI::Kit::ReportErrors.call(exception || $ERROR_INFO, logfile_path, error_reporter) + end + end + + def self.call(error, logfile_path, error_reporter) + notify_with = nil + + case error + when nil # normal, non-error termination + when Interrupt # ctrl-c + when CLI::Kit::Abort, CLI::Kit::AbortSilent # Not a bug + when SignalException + skip = %w(SIGTERM SIGHUP SIGINT) + unless skip.include?(error.message) + notify_with = error + end + when SystemExit # "exit N" called + case error.status + when CLI::Kit::EXIT_SUCCESS # submit nothing if it was `exit 0` + when CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG + # if it was `exit 30`, translate the exit code to 1, and submit nothing + # 30 is used to signal normal failures that are not indicative of bugs. + # But users should see it presented as 1. + exit 1 + else + # A weird termination status happened. `error.exception "message"` will maintain backtrace + # but allow us to set a message + notify_with = error.exception "abnormal termination status: #{error.status}" + end + else + notify_with = error + end + + if notify_with + logs = begin + File.read(logfile_path) + rescue => e + "(#{e.class}: #{e.message})" + end + error_reporter.call(notify_with, logs) + end + end + end + end +end diff --git a/lib/cli/kit/version.rb b/lib/cli/kit/version.rb index cead6bc..edcf150 100644 --- a/lib/cli/kit/version.rb +++ b/lib/cli/kit/version.rb @@ -1,5 +1,5 @@ module CLI module Kit - VERSION = "1.0.0" + VERSION = "2.0.0" end end diff --git a/test/cli/kit/base_command_test.rb b/test/cli/kit/base_command_test.rb new file mode 100644 index 0000000..a28d3d7 --- /dev/null +++ b/test/cli/kit/base_command_test.rb @@ -0,0 +1,117 @@ +require 'test_helper' + +module CLI + module Kit + class BaseCommandTest < MiniTest::Test + class ExampleCommand < BaseCommand + def self.stat(*) + nil + end + + def self.statsd_increment(metric, **kwargs) + stat(:increment, metric, **kwargs) + end + + def self.statsd_time(metric, **kwargs) + a = yield + stat(:time, metric, **kwargs) + a + end + + def call(args, _name) + end + end + + def expected_tags + ["task:CLI::Kit::BaseCommandTest::ExampleCommand"] + end + + def test_self_call_sends_statsd_on_success + ExampleCommand.expects(:stat).with( + :increment, + "cli.command.invoked", + tags: expected_tags + ) + ExampleCommand.any_instance.expects(:call).with([], "command") + ExampleCommand.expects(:stat).with( + :time, + "cli.command.time", + tags: expected_tags + ) + ExampleCommand.expects(:stat).with( + :increment, + "cli.command.success", + tags: expected_tags + ) + + ExampleCommand.call([], "command") + end + + def test_self_call_sends_statsd_on_failure + ExampleCommand.expects(:stat).with( + :increment, + "cli.command.invoked", + tags: expected_tags + ) + ExampleCommand.any_instance.expects(:call) + .with([], "command") + .raises(RuntimeError, 'something went wrong.') + + ExampleCommand.expects(:stat).with( + :increment, + "cli.command.exception", + tags: expected_tags + ["exception:RuntimeError"] + ) + + e = assert_raises RuntimeError do + ExampleCommand.call([], "command") + end + assert_equal 'something went wrong.', e.message + end + + def test_self_call_adds_subcommand_tag_and_fails + ExampleCommand.any_instance.expects(:has_subcommands?).returns(true) + + ExampleCommand.expects(:stat).with( + :increment, + "cli.command.invoked", + tags: expected_tags + ["subcommand:test"] + ) + ExampleCommand.any_instance.expects(:call) + .with(['test'], "command") + .raises(RuntimeError, 'something went wrong.') + + ExampleCommand.expects(:stat).with( + :increment, + "cli.command.exception", + tags: expected_tags + ["subcommand:test", "exception:RuntimeError"] + ) + + e = assert_raises RuntimeError do + ExampleCommand.call(['test'], "command") + end + assert_equal 'something went wrong.', e.message + end + + def test_self_call_records_time + ExampleCommand.expects(:stat).with( + :increment, + "cli.command.invoked", + tags: expected_tags + ) + ExampleCommand.expects(:stat).with( + :time, + "cli.command.time", + tags: expected_tags + ) + ExampleCommand.expects(:stat).with( + :increment, + "cli.command.success", + tags: expected_tags + ) + + ExampleCommand.call([], "command") + end + end + end +end diff --git a/test/cli/kit/config_test.rb b/test/cli/kit/config_test.rb new file mode 100644 index 0000000..b0a75ae --- /dev/null +++ b/test/cli/kit/config_test.rb @@ -0,0 +1,69 @@ +require 'test_helper' +require 'tmpdir' +require 'fileutils' + +module CLI + module Kit + class ConfigTest < MiniTest::Test + def setup + super + + CLI::Kit.tool_name ||= 'tool' + + @tmpdir = Dir.mktmpdir + @prev_xdg = ENV['XDG_CONFIG_HOME'] + ENV['XDG_CONFIG_HOME'] = @tmpdir + @file = File.join(@tmpdir, 'tool', 'config') + + @config = Config.new + end + + def teardown + FileUtils.rm_rf(@tmpdir) + ENV['XDG_CONFIG_HOME'] = @prev_xdg + super + end + + def test_config_get_returns_false_for_not_existant_key + refute @config.get('invalid-key-no-existing') + end + + def test_config_key_never_padded_with_whitespace + # There was a bug that occured when a key was reset + # We split on `=` and 'key ' became the new key (with a space) + # This is a regression test to make sure that doesnt happen + @config.set('key', 'value') + assert_equal({ "[global]" => { "key" => "value" } }, @config.send(:all_configs)) + 3.times { @config.set('key', 'value') } + assert_equal({ "[global]" => { "key" => "value" } }, @config.send(:all_configs)) + end + + def test_config_set + @config.set('some-key', '~/.test') + assert_equal("[global]\nsome-key = ~/.test", File.read(@file)) + + @config.set('some-key', nil) + assert_equal '', File.read(@file) + + @config.set('some-key', '~/.test') + @config.set('some-other-key', '~/.test') + assert_equal("[global]\nsome-key = ~/.test\nsome-other-key = ~/.test", File.read(@file)) + + assert_equal('~/.test', @config.get('some-key')) + assert_equal("#{ENV['HOME']}/.test", @config.get_path('some-key')) + end + + def test_config_mutli_argument_get + @config.set('some-parent.some-key', 'some-value') + assert_equal 'some-value', @config.get('some-parent', 'some-key') + end + + def test_get_section + @config.set('some-key', 'should not show') + @config.set('srcpath.other', 'test') + @config.set('srcpath.default', 'Shopify') + assert_equal({ 'other' => 'test', 'default' => 'Shopify' }, @config.get_section('srcpath')) + end + end + end +end diff --git a/test/fixtures/ini_with_and_without_heading.conf b/test/fixtures/ini_with_and_without_heading.conf new file mode 100644 index 0000000..cad027f --- /dev/null +++ b/test/fixtures/ini_with_and_without_heading.conf @@ -0,0 +1,8 @@ +key = val +key2 = val + +[global] +key = val +key2 = val + +key3 = val diff --git a/test/fixtures/ini_with_heading.conf b/test/fixtures/ini_with_heading.conf new file mode 100644 index 0000000..1913465 --- /dev/null +++ b/test/fixtures/ini_with_heading.conf @@ -0,0 +1,3 @@ +[global] +key = val +key2 = val \ No newline at end of file diff --git a/test/fixtures/ini_with_types.conf b/test/fixtures/ini_with_types.conf new file mode 100644 index 0000000..c674a53 --- /dev/null +++ b/test/fixtures/ini_with_types.conf @@ -0,0 +1,3 @@ +[global] +key = 1 +key2 = 1.0 \ No newline at end of file diff --git a/test/fixtures/ini_without_heading.conf b/test/fixtures/ini_without_heading.conf new file mode 100644 index 0000000..0f4d2c3 --- /dev/null +++ b/test/fixtures/ini_without_heading.conf @@ -0,0 +1,2 @@ +key = val +key2 = val