diff --git a/lib/wavesync/cli.rb b/lib/wavesync/cli.rb index 58ea3b2..7f0b759 100644 --- a/lib/wavesync/cli.rb +++ b/lib/wavesync/cli.rb @@ -42,7 +42,7 @@ def self.start_sync parser.parse! config_path = options[:config] || Wavesync::Config::DEFAULT_PATH - config = Wavesync::Config.load(config_path) + config = load_config(config_path) device_configs = config.device_configs if options[:device] @@ -85,7 +85,7 @@ def self.start_set parser.parse! config_path = options[:config] || Wavesync::Config::DEFAULT_PATH - config = Wavesync::Config.load(config_path) + config = load_config(config_path) case subcommand when 'create' @@ -118,6 +118,13 @@ def self.start_set end end + def self.load_config(path) + Wavesync::Config.load(path) + rescue Wavesync::ConfigError => e + puts "Configuration error: #{e.message}" + exit 1 + end + def self.require_set_name(subcommand) name = ARGV.shift unless name @@ -144,7 +151,7 @@ def self.start_analyze parser.parse! config_path = options[:config] || Wavesync::Config::DEFAULT_PATH - config = Wavesync::Config.load(config_path) + config = load_config(config_path) Wavesync::Analyzer.new(config.library).analyze(overwrite: options[:overwrite] || false) end diff --git a/lib/wavesync/config.rb b/lib/wavesync/config.rb index 2c01fad..bd01cae 100644 --- a/lib/wavesync/config.rb +++ b/lib/wavesync/config.rb @@ -3,21 +3,68 @@ require 'yaml' module Wavesync + class ConfigError < StandardError; end + class Config DEFAULT_PATH = File.join(Dir.home, 'wavesync.yml') + SUPPORTED_KEYS = %w[library devices].freeze + DEVICE_SUPPORTED_KEYS = %w[name model path].freeze + DEVICE_REQUIRED_KEYS = %w[name model path].freeze + attr_reader :library, :device_configs def self.load(path = DEFAULT_PATH) - data = YAML.load_file(File.expand_path(path)) + expanded = File.expand_path(path) + begin + data = YAML.load_file(expanded) + rescue Errno::ENOENT + raise ConfigError, "Config file not found: #{path}" + rescue Psych::SyntaxError => e + raise ConfigError, "Invalid YAML in config file: #{e.message}" + end new(data) end def initialize(data) - @library = File.expand_path(data.fetch('library')) - @device_configs = data.fetch('devices').map do |device| + validate!(data) + @library = File.expand_path(data['library']) + @device_configs = data['devices'].each_with_index.map do |device, i| + validate_device!(device, i) { name: device['name'], model: device['model'], path: File.expand_path(device['path']) } end end + + private + + def validate!(data) + raise ConfigError, 'Config file must contain a YAML mapping' unless data.is_a?(Hash) + + unsupported = data.keys - SUPPORTED_KEYS + raise ConfigError, "Unsupported config keys: #{unsupported.join(', ')}" if unsupported.any? + + %w[library devices].each do |key| + raise ConfigError, "Missing required config key: '#{key}'" unless data.key?(key) + end + + raise ConfigError, "'library' must be a string" unless data['library'].is_a?(String) + raise ConfigError, "'devices' must be a list" unless data['devices'].is_a?(Array) + raise ConfigError, "'devices' must contain at least one device" if data['devices'].empty? + end + + def validate_device!(device, index) + raise ConfigError, "Device #{index + 1} must be a YAML mapping" unless device.is_a?(Hash) + + unsupported = device.keys - DEVICE_SUPPORTED_KEYS + raise ConfigError, "Unsupported keys in device #{index + 1}: #{unsupported.join(', ')}" if unsupported.any? + + DEVICE_REQUIRED_KEYS.each do |key| + raise ConfigError, "Missing required key '#{key}' in device #{index + 1}" unless device.key?(key) + end + + %w[name model path].each do |key| + raise ConfigError, "Device #{index + 1} '#{key}' must be a string" unless device[key].is_a?(String) + end + end end end diff --git a/test/wavesync/config_test.rb b/test/wavesync/config_test.rb new file mode 100644 index 0000000..af46dd2 --- /dev/null +++ b/test/wavesync/config_test.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require 'tempfile' +require_relative 'test_case' +require_relative '../../lib/wavesync/config' + +module Wavesync + class ConfigTest < Wavesync::TestCase + VALID_CONFIG = { + 'library' => '/tmp/library', + 'devices' => [ + { 'name' => 'My Device', 'model' => 'TP-7', 'path' => '/tmp/device' } + ] + }.freeze + + test 'load raises ConfigError when file does not exist' do + error = assert_raises(ConfigError) { Config.load('/nonexistent/path/wavesync.yml') } + assert_match 'Config file not found', error.message + end + + test 'load raises ConfigError for malformed YAML' do + file = Tempfile.new(['wavesync', '.yml']) + file.write("library: /tmp\ndevices: [\nbad yaml{{") + file.close + error = assert_raises(ConfigError) { Config.load(file.path) } + assert_match 'Invalid YAML in config file', error.message + ensure + file.unlink + end + + test 'load succeeds with valid config file' do + file = Tempfile.new(['wavesync', '.yml']) + file.write("library: /tmp\ndevices:\n - name: My Device\n model: TP-7\n path: /tmp/device\n") + file.close + config = Config.load(file.path) + assert_equal File.expand_path('/tmp'), config.library + ensure + file.unlink + end + + test 'raises ConfigError when YAML root is not a hash' do + error = assert_raises(ConfigError) { Config.new(['not', 'a', 'hash']) } + assert_match 'must contain a YAML mapping', error.message + end + + test 'raises ConfigError when YAML root is nil' do + error = assert_raises(ConfigError) { Config.new(nil) } + assert_match 'must contain a YAML mapping', error.message + end + + test 'raises ConfigError for unsupported top-level keys' do + data = VALID_CONFIG.merge('unknown_key' => 'value') + error = assert_raises(ConfigError) { Config.new(data) } + assert_match 'Unsupported config keys', error.message + assert_match 'unknown_key', error.message + end + + test 'raises ConfigError when library key is missing' do + data = VALID_CONFIG.reject { |k, _| k == 'library' } + error = assert_raises(ConfigError) { Config.new(data) } + assert_match "Missing required config key: 'library'", error.message + end + + test 'raises ConfigError when devices key is missing' do + data = VALID_CONFIG.reject { |k, _| k == 'devices' } + error = assert_raises(ConfigError) { Config.new(data) } + assert_match "Missing required config key: 'devices'", error.message + end + + test 'raises ConfigError when library is not a string' do + data = VALID_CONFIG.merge('library' => ['not', 'a', 'string']) + error = assert_raises(ConfigError) { Config.new(data) } + assert_match "'library' must be a string", error.message + end + + test 'raises ConfigError when devices is not an array' do + data = VALID_CONFIG.merge('devices' => 'not an array') + error = assert_raises(ConfigError) { Config.new(data) } + assert_match "'devices' must be a list", error.message + end + + test 'raises ConfigError when devices is empty' do + data = VALID_CONFIG.merge('devices' => []) + error = assert_raises(ConfigError) { Config.new(data) } + assert_match "'devices' must contain at least one device", error.message + end + + test 'raises ConfigError when a device entry is not a hash' do + data = VALID_CONFIG.merge('devices' => ['not a hash']) + error = assert_raises(ConfigError) { Config.new(data) } + assert_match 'Device 1 must be a YAML mapping', error.message + end + + test 'raises ConfigError for unsupported device keys' do + device = { 'name' => 'My Device', 'model' => 'TP-7', 'path' => '/tmp', 'extra' => 'bad' } + data = VALID_CONFIG.merge('devices' => [device]) + error = assert_raises(ConfigError) { Config.new(data) } + assert_match 'Unsupported keys in device 1', error.message + assert_match 'extra', error.message + end + + test 'raises ConfigError when device name is missing' do + device = { 'model' => 'TP-7', 'path' => '/tmp' } + data = VALID_CONFIG.merge('devices' => [device]) + error = assert_raises(ConfigError) { Config.new(data) } + assert_match "Missing required key 'name' in device 1", error.message + end + + test 'raises ConfigError when device model is missing' do + device = { 'name' => 'My Device', 'path' => '/tmp' } + data = VALID_CONFIG.merge('devices' => [device]) + error = assert_raises(ConfigError) { Config.new(data) } + assert_match "Missing required key 'model' in device 1", error.message + end + + test 'raises ConfigError when device path is missing' do + device = { 'name' => 'My Device', 'model' => 'TP-7' } + data = VALID_CONFIG.merge('devices' => [device]) + error = assert_raises(ConfigError) { Config.new(data) } + assert_match "Missing required key 'path' in device 1", error.message + end + + test 'raises ConfigError when device name is not a string' do + device = { 'name' => 123, 'model' => 'TP-7', 'path' => '/tmp' } + data = VALID_CONFIG.merge('devices' => [device]) + error = assert_raises(ConfigError) { Config.new(data) } + assert_match "Device 1 'name' must be a string", error.message + end + + test 'raises ConfigError when device model is not a string' do + device = { 'name' => 'My Device', 'model' => ['TP-7'], 'path' => '/tmp' } + data = VALID_CONFIG.merge('devices' => [device]) + error = assert_raises(ConfigError) { Config.new(data) } + assert_match "Device 1 'model' must be a string", error.message + end + + test 'raises ConfigError when device path is not a string' do + device = { 'name' => 'My Device', 'model' => 'TP-7', 'path' => { 'bad' => 'type' } } + data = VALID_CONFIG.merge('devices' => [device]) + error = assert_raises(ConfigError) { Config.new(data) } + assert_match "Device 1 'path' must be a string", error.message + end + + test 'error message references correct device index for second device' do + valid_device = { 'name' => 'First', 'model' => 'TP-7', 'path' => '/tmp' } + bad_device = { 'name' => 'Second', 'model' => 'TP-7' } + data = VALID_CONFIG.merge('devices' => [valid_device, bad_device]) + error = assert_raises(ConfigError) { Config.new(data) } + assert_match 'device 2', error.message + end + + test 'initializes with valid data' do + config = Config.new(VALID_CONFIG) + assert_equal File.expand_path('/tmp/library'), config.library + assert_equal 1, config.device_configs.size + assert_equal 'My Device', config.device_configs.first[:name] + assert_equal 'TP-7', config.device_configs.first[:model] + assert_equal File.expand_path('/tmp/device'), config.device_configs.first[:path] + end + + test 'initializes with multiple devices' do + data = VALID_CONFIG.merge('devices' => [ + { 'name' => 'Device A', 'model' => 'TP-7', 'path' => '/tmp/a' }, + { 'name' => 'Device B', 'model' => 'Octatrack', 'path' => '/tmp/b' } + ]) + config = Config.new(data) + assert_equal 2, config.device_configs.size + end + end +end