Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions lib/wavesync/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
53 changes: 50 additions & 3 deletions lib/wavesync/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
170 changes: 170 additions & 0 deletions test/wavesync/config_test.rb
Original file line number Diff line number Diff line change
@@ -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