From 7ef7a3eb44f0d4814b465ef7af1eaf70ab4ad4e2 Mon Sep 17 00:00:00 2001 From: Simon Lauger Date: Sun, 25 Jan 2026 21:06:31 +0100 Subject: [PATCH 01/10] Add Hiera defaults support for module documentation This enhancement allows openvox-strings to automatically extract and display parameter defaults from Hiera data (common.yaml) in addition to code defaults. Features: - New Hiera parser class that reads hiera.yaml and data/common.yaml - Automatic module root detection from manifest file paths - Seamless integration with existing markdown generator - Code defaults take precedence over Hiera defaults - Support for all Puppet data types (strings, integers, booleans, hashes, arrays, undef) Implementation: - lib/openvox-strings/hiera.rb: Core Hiera parsing and value conversion - lib/openvox-strings/markdown/base.rb: Extended defaults() method to merge Hiera data - spec/unit/openvox-strings/hiera_spec.rb: Comprehensive unit tests This allows module maintainers to define defaults in data/common.yaml without duplicating them in the Puppet class parameters, while still generating complete documentation. --- lib/openvox-strings/hiera.rb | 111 +++++++++++++ lib/openvox-strings/markdown/base.rb | 74 ++++++++- spec/unit/openvox-strings/hiera_spec.rb | 202 ++++++++++++++++++++++++ 3 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 lib/openvox-strings/hiera.rb create mode 100644 spec/unit/openvox-strings/hiera_spec.rb diff --git a/lib/openvox-strings/hiera.rb b/lib/openvox-strings/hiera.rb new file mode 100644 index 00000000..20351245 --- /dev/null +++ b/lib/openvox-strings/hiera.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'yaml' + +module OpenvoxStrings + # Parser for Hiera configuration and data + class Hiera + attr_reader :hiera_config, :common_data + + # Initializes a Hiera parser for a given module path + # @param [String] module_path The path to the Puppet module root directory + def initialize(module_path) + @module_path = module_path + @hiera_config = load_hiera_config + @common_data = load_common_data + end + + # Checks if Hiera is configured for this module + # @return [Boolean] true if hiera.yaml exists and is valid + def hiera_enabled? + !@hiera_config.nil? + end + + # Gets the default value for a parameter from Hiera data + # @param [String] class_name The fully qualified class name (e.g., 'github_actions_runner') + # @param [String] param_name The parameter name + # @return [String, nil] The default value as a string, or nil if not found + def lookup_default(class_name, param_name) + return nil unless hiera_enabled? + return nil if @common_data.nil? + + # Try to lookup with class prefix: modulename::parametername + key = "#{class_name}::#{param_name}" + return nil unless @common_data.key?(key) + + value = @common_data[key] + + # Convert value to Puppet-compatible string representation + value_to_puppet_string(value) + end + + private + + # Loads and parses hiera.yaml from the module root + # @return [Hash, nil] The parsed hiera configuration, or nil if not found/invalid + def load_hiera_config + hiera_file = File.join(@module_path, 'hiera.yaml') + return nil unless File.exist?(hiera_file) + + begin + YAML.load_file(hiera_file) + rescue StandardError => e + YARD::Logger.instance.warn "Failed to parse hiera.yaml: #{e.message}" + nil + end + end + + # Loads and parses data/common.yaml from the module + # @return [Hash, nil] The parsed common data, or nil if not found/invalid + def load_common_data + return nil unless hiera_enabled? + + # Get datadir from hiera config (defaults to 'data') + datadir = @hiera_config.dig('defaults', 'datadir') || 'data' + common_file = File.join(@module_path, datadir, 'common.yaml') + + return nil unless File.exist?(common_file) + + begin + YAML.load_file(common_file) + rescue StandardError => e + YARD::Logger.instance.warn "Failed to parse common.yaml: #{e.message}" + nil + end + end + + # Converts a Ruby value to a Puppet-compatible string representation + # @param [Object] value The value to convert + # @return [String] The Puppet-compatible string representation + def value_to_puppet_string(value) + case value + when String + # Empty strings from YAML nil (~) should be undef + return 'undef' if value.empty? + + # Strings should be quoted + "'#{value}'" + when Integer, Float + # Numbers are unquoted + value.to_s + when TrueClass, FalseClass + # Booleans are lowercase + value.to_s + when NilClass, :undef + # Puppet undef + 'undef' + when Hash + # Convert hash to Puppet hash syntax + pairs = value.map { |k, v| "'#{k}' => #{value_to_puppet_string(v)}" } + "{ #{pairs.join(', ')} }" + when Array + # Convert array to Puppet array syntax + elements = value.map { |v| value_to_puppet_string(v) } + "[ #{elements.join(', ')} ]" + else + # Fallback: convert to string and quote + "'#{value}'" + end + end + end +end diff --git a/lib/openvox-strings/markdown/base.rb b/lib/openvox-strings/markdown/base.rb index 7a85a287..f003d5b3 100644 --- a/lib/openvox-strings/markdown/base.rb +++ b/lib/openvox-strings/markdown/base.rb @@ -4,6 +4,7 @@ require 'openvox-strings/json' require 'openvox-strings/yard' require 'openvox-strings/markdown/helpers' +require 'openvox-strings/hiera' # Implements classes that make elements in a YARD::Registry hash easily accessible for template. module OpenvoxStrings::Markdown @@ -86,6 +87,7 @@ def initialize(registry, component_type) @type = component_type @registry = registry @tags = registry[:docstring][:tags] || [] + @hiera = initialize_hiera end # generate 1:1 tag methods @@ -174,7 +176,13 @@ def enums_for_param(parameter_name) # @return [Hash] any defaults found for the component def defaults - @registry[:defaults] unless @registry[:defaults].nil? + # Start with code defaults from the Puppet class + code_defaults = @registry[:defaults] || {} + + # Try to merge with Hiera defaults if available + merged_defaults = merge_hiera_defaults(code_defaults) + + merged_defaults.empty? ? nil : merged_defaults end # @return [Hash] information needed for the table of contents @@ -216,6 +224,70 @@ def render(template) private + # Initializes Hiera integration for this component + # @return [OpenvoxStrings::Hiera, nil] Hiera instance or nil if not available + def initialize_hiera + return nil unless @registry[:file] + + # Find the module root directory from the file path + # Puppet modules have manifests/, lib/, data/ etc. at the root + module_path = find_module_root(@registry[:file]) + return nil unless module_path + + OpenvoxStrings::Hiera.new(module_path) + rescue StandardError => e + YARD::Logger.instance.debug "Failed to initialize Hiera: #{e.message}" + nil + end + + # Finds the module root directory from a file path + # @param [String] file_path The path to a file in the module + # @return [String, nil] The module root path or nil if not found + def find_module_root(file_path) + current_path = File.dirname(File.expand_path(file_path)) + + # Walk up the directory tree looking for module indicators + 10.times do + # Check if this looks like a module root (has manifests/, lib/, or hiera.yaml) + if File.exist?(File.join(current_path, 'hiera.yaml')) || + (File.exist?(File.join(current_path, 'manifests')) && File.exist?(File.join(current_path, 'metadata.json'))) + return current_path + end + + parent = File.dirname(current_path) + break if parent == current_path # Reached filesystem root + + current_path = parent + end + + nil + end + + # Merges code defaults with Hiera defaults + # @param [Hash] code_defaults The defaults from the Puppet code + # @return [Hash] Merged defaults with code defaults taking precedence + def merge_hiera_defaults(code_defaults) + return code_defaults unless @hiera&.hiera_enabled? + + # Start with Hiera defaults + merged = {} + + # Get all parameters from the docstring + param_tags = @tags.select { |tag| tag[:tag_name] == 'param' } + + param_tags.each do |param_tag| + param_name = param_tag[:name] + next unless param_name + + # Try to get default from Hiera + hiera_default = @hiera.lookup_default(name, param_name) + merged[param_name] = hiera_default if hiera_default + end + + # Code defaults override Hiera defaults + merged.merge(code_defaults) + end + def select_tags(name) tags = @tags.select { |tag| tag[:tag_name] == name } tags.empty? ? nil : tags diff --git a/spec/unit/openvox-strings/hiera_spec.rb b/spec/unit/openvox-strings/hiera_spec.rb new file mode 100644 index 00000000..192258aa --- /dev/null +++ b/spec/unit/openvox-strings/hiera_spec.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'openvox-strings/hiera' +require 'tmpdir' +require 'fileutils' + +describe OpenvoxStrings::Hiera do + let(:temp_dir) { Dir.mktmpdir } + + after do + FileUtils.remove_entry(temp_dir) + end + + describe '#initialize' do + context 'when hiera.yaml exists' do + before do + File.write(File.join(temp_dir, 'hiera.yaml'), <<~YAML) + version: 5 + defaults: + datadir: data + data_hash: yaml_data + hierarchy: + - name: "Common defaults" + path: "common.yaml" + YAML + end + + it 'loads hiera configuration' do + hiera = described_class.new(temp_dir) + expect(hiera.hiera_config).not_to be_nil + expect(hiera.hiera_config['version']).to eq(5) + end + + it 'is enabled' do + hiera = described_class.new(temp_dir) + expect(hiera).to be_hiera_enabled + end + end + + context 'when hiera.yaml does not exist' do + it 'is not enabled' do + hiera = described_class.new(temp_dir) + expect(hiera).not_to be_hiera_enabled + end + end + end + + describe '#load_common_data' do + context 'when common.yaml exists' do + before do + File.write(File.join(temp_dir, 'hiera.yaml'), <<~YAML) + version: 5 + defaults: + datadir: data + data_hash: yaml_data + hierarchy: + - name: "Common defaults" + path: "common.yaml" + YAML + + FileUtils.mkdir_p(File.join(temp_dir, 'data')) + File.write(File.join(temp_dir, 'data', 'common.yaml'), <<~YAML) + testmodule::param1: 'value1' + testmodule::param2: 42 + testmodule::param3: true + testmodule::param4: ~ + YAML + end + + it 'loads common data' do + hiera = described_class.new(temp_dir) + expect(hiera.common_data).not_to be_nil + expect(hiera.common_data['testmodule::param1']).to eq('value1') + end + end + end + + describe '#lookup_default' do + before do + File.write(File.join(temp_dir, 'hiera.yaml'), <<~YAML) + version: 5 + defaults: + datadir: data + data_hash: yaml_data + hierarchy: + - name: "Common defaults" + path: "common.yaml" + YAML + + FileUtils.mkdir_p(File.join(temp_dir, 'data')) + File.write(File.join(temp_dir, 'data', 'common.yaml'), <<~YAML) + circus::tent_size: 'large' + circus::location: '/opt/circus-ground' + circus::season: '2025' + circus::ringmaster: 'Giovanni' + circus::has_animals: false + circus::performers: {} + circus::insurance_policy: ~ + YAML + end + + let(:hiera) { described_class.new(temp_dir) } + + it 'returns string values with quotes' do + result = hiera.lookup_default('circus', 'tent_size') + expect(result).to eq("'large'") + end + + it 'returns string paths with quotes' do + result = hiera.lookup_default('circus', 'location') + expect(result).to eq("'/opt/circus-ground'") + end + + it 'returns string version numbers with quotes' do + result = hiera.lookup_default('circus', 'season') + expect(result).to eq("'2025'") + end + + it 'returns string names with quotes' do + result = hiera.lookup_default('circus', 'ringmaster') + expect(result).to eq("'Giovanni'") + end + + it 'returns boolean false as false' do + result = hiera.lookup_default('circus', 'has_animals') + expect(result).to eq('false') + end + + it 'returns empty hash as {}' do + result = hiera.lookup_default('circus', 'performers') + expect(result).to eq('{ }') + end + + it 'returns nil/undef as undef' do + result = hiera.lookup_default('circus', 'insurance_policy') + expect(result).to eq('undef') + end + + it 'returns nil for non-existent keys' do + result = hiera.lookup_default('circus', 'nonexistent') + expect(result).to be_nil + end + + it 'returns nil for wrong class name' do + result = hiera.lookup_default('wrong_module', 'tent_size') + expect(result).to be_nil + end + end + + describe '#value_to_puppet_string' do + let(:hiera) { described_class.new(temp_dir) } + + it 'converts strings with quotes' do + expect(hiera.send(:value_to_puppet_string, 'hello')).to eq("'hello'") + end + + it 'converts integers without quotes' do + expect(hiera.send(:value_to_puppet_string, 42)).to eq('42') + end + + it 'converts floats without quotes' do + expect(hiera.send(:value_to_puppet_string, 3.14)).to eq('3.14') + end + + it 'converts true to lowercase true' do + expect(hiera.send(:value_to_puppet_string, true)).to eq('true') + end + + it 'converts false to lowercase false' do + expect(hiera.send(:value_to_puppet_string, false)).to eq('false') + end + + it 'converts nil to undef' do + expect(hiera.send(:value_to_puppet_string, nil)).to eq('undef') + end + + it 'converts empty arrays' do + expect(hiera.send(:value_to_puppet_string, [])).to eq('[ ]') + end + + it 'converts arrays with values' do + expect(hiera.send(:value_to_puppet_string, ['a', 'b'])).to eq("[ 'a', 'b' ]") + end + + it 'converts empty hashes' do + expect(hiera.send(:value_to_puppet_string, {})).to eq('{ }') + end + + it 'converts hashes with values' do + result = hiera.send(:value_to_puppet_string, { 'key' => 'value' }) + expect(result).to eq("{ 'key' => 'value' }") + end + + it 'converts nested structures' do + nested = { 'array' => [1, 2], 'nested_hash' => { 'key' => 'value' } } + result = hiera.send(:value_to_puppet_string, nested) + expect(result).to include("'array' => [ 1, 2 ]") + expect(result).to include("'nested_hash' => { 'key' => 'value' }") + end + end +end From cc5f17ac5c56b1fee1b8eeacff9b4175b0fe3952 Mon Sep 17 00:00:00 2001 From: Simon Lauger Date: Sun, 25 Jan 2026 21:32:35 +0100 Subject: [PATCH 02/10] Fix empty hash/array formatting to match code defaults Empty hashes and arrays should be formatted as {} and [] (without spaces) to match the formatting of code defaults, ensuring identical REFERENCE.md generation. --- lib/openvox-strings/hiera.rb | 4 ++++ spec/unit/openvox-strings/hiera_spec.rb | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/openvox-strings/hiera.rb b/lib/openvox-strings/hiera.rb index 20351245..c07e3532 100644 --- a/lib/openvox-strings/hiera.rb +++ b/lib/openvox-strings/hiera.rb @@ -96,10 +96,14 @@ def value_to_puppet_string(value) 'undef' when Hash # Convert hash to Puppet hash syntax + return '{}' if value.empty? + pairs = value.map { |k, v| "'#{k}' => #{value_to_puppet_string(v)}" } "{ #{pairs.join(', ')} }" when Array # Convert array to Puppet array syntax + return '[]' if value.empty? + elements = value.map { |v| value_to_puppet_string(v) } "[ #{elements.join(', ')} ]" else diff --git a/spec/unit/openvox-strings/hiera_spec.rb b/spec/unit/openvox-strings/hiera_spec.rb index 192258aa..b9052097 100644 --- a/spec/unit/openvox-strings/hiera_spec.rb +++ b/spec/unit/openvox-strings/hiera_spec.rb @@ -129,7 +129,7 @@ it 'returns empty hash as {}' do result = hiera.lookup_default('circus', 'performers') - expect(result).to eq('{ }') + expect(result).to eq('{}') end it 'returns nil/undef as undef' do @@ -176,7 +176,7 @@ end it 'converts empty arrays' do - expect(hiera.send(:value_to_puppet_string, [])).to eq('[ ]') + expect(hiera.send(:value_to_puppet_string, [])).to eq('[]') end it 'converts arrays with values' do @@ -184,7 +184,7 @@ end it 'converts empty hashes' do - expect(hiera.send(:value_to_puppet_string, {})).to eq('{ }') + expect(hiera.send(:value_to_puppet_string, {})).to eq('{}') end it 'converts hashes with values' do From a4178685fbafbdb1b15bb4cb6b4fb5c900de21d5 Mon Sep 17 00:00:00 2001 From: Simon Lauger Date: Sun, 25 Jan 2026 22:48:31 +0100 Subject: [PATCH 03/10] Fix array formatting to match code defaults output Arrays must not have spaces around brackets to match the format used by puppet-strings when reading defaults from code: - Code format: ['a', 'b', 'c'] - Hiera format was: [ 'a', 'b', 'c' ] (WRONG) - Hiera format now: ['a', 'b', 'c'] (CORRECT) This ensures byte-for-byte identical REFERENCE.md output whether defaults come from code or from Hiera data. --- lib/openvox-strings/hiera.rb | 6 +++--- spec/unit/openvox-strings/hiera_spec.rb | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/openvox-strings/hiera.rb b/lib/openvox-strings/hiera.rb index c07e3532..e3d5ed13 100644 --- a/lib/openvox-strings/hiera.rb +++ b/lib/openvox-strings/hiera.rb @@ -95,17 +95,17 @@ def value_to_puppet_string(value) # Puppet undef 'undef' when Hash - # Convert hash to Puppet hash syntax + # Convert hash to Puppet hash syntax (no spaces to match code defaults format) return '{}' if value.empty? pairs = value.map { |k, v| "'#{k}' => #{value_to_puppet_string(v)}" } "{ #{pairs.join(', ')} }" when Array - # Convert array to Puppet array syntax + # Convert array to Puppet array syntax (no spaces to match code defaults format) return '[]' if value.empty? elements = value.map { |v| value_to_puppet_string(v) } - "[ #{elements.join(', ')} ]" + "[#{elements.join(', ')}]" else # Fallback: convert to string and quote "'#{value}'" diff --git a/spec/unit/openvox-strings/hiera_spec.rb b/spec/unit/openvox-strings/hiera_spec.rb index b9052097..5d4864a5 100644 --- a/spec/unit/openvox-strings/hiera_spec.rb +++ b/spec/unit/openvox-strings/hiera_spec.rb @@ -180,7 +180,7 @@ end it 'converts arrays with values' do - expect(hiera.send(:value_to_puppet_string, ['a', 'b'])).to eq("[ 'a', 'b' ]") + expect(hiera.send(:value_to_puppet_string, ['a', 'b'])).to eq("['a', 'b']") end it 'converts empty hashes' do @@ -195,7 +195,7 @@ it 'converts nested structures' do nested = { 'array' => [1, 2], 'nested_hash' => { 'key' => 'value' } } result = hiera.send(:value_to_puppet_string, nested) - expect(result).to include("'array' => [ 1, 2 ]") + expect(result).to include("'array' => [1, 2]") expect(result).to include("'nested_hash' => { 'key' => 'value' }") end end From 81dbab3770461a97a61ed6ff5b8f4b519cb14472 Mon Sep 17 00:00:00 2001 From: Simon Lauger Date: Sun, 25 Jan 2026 22:59:25 +0100 Subject: [PATCH 04/10] Add tests for array formatting without spaces During development we discovered that arrays must be formatted without spaces around brackets to match puppet-strings code defaults format: - Correct: ['a', 'b', 'c'] - Wrong: [ 'a', 'b', 'c' ] Added explicit tests to ensure this formatting is preserved: - String arrays without spaces - Integer arrays without spaces - Nested arrays in hashes without spaces --- spec/unit/openvox-strings/hiera_spec.rb | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/spec/unit/openvox-strings/hiera_spec.rb b/spec/unit/openvox-strings/hiera_spec.rb index 5d4864a5..561aec60 100644 --- a/spec/unit/openvox-strings/hiera_spec.rb +++ b/spec/unit/openvox-strings/hiera_spec.rb @@ -198,5 +198,28 @@ expect(result).to include("'array' => [1, 2]") expect(result).to include("'nested_hash' => { 'key' => 'value' }") end + + it 'formats arrays without spaces to match code defaults' do + # Arrays must use ['a', 'b'] not [ 'a', 'b' ] to match puppet-strings code format + result = hiera.send(:value_to_puppet_string, ['alpha', 'beta', 'gamma']) + expect(result).to eq("['alpha', 'beta', 'gamma']") + expect(result).not_to include('[ ') + expect(result).not_to include(' ]') + end + + it 'formats integer arrays without spaces to match code defaults' do + result = hiera.send(:value_to_puppet_string, [1, 2, 3, 5, 8]) + expect(result).to eq('[1, 2, 3, 5, 8]') + expect(result).not_to include('[ ') + expect(result).not_to include(' ]') + end + + it 'formats nested arrays in hashes without spaces' do + nested = { 'tags' => ['prod', 'eu'] } + result = hiera.send(:value_to_puppet_string, nested) + expect(result).to eq("{ 'tags' => ['prod', 'eu'] }") + expect(result).not_to include('[ ') + expect(result).not_to include(' ]') + end end end From 7877167f18ffd908c131f0623060f12c671a1409 Mon Sep 17 00:00:00 2001 From: Simon Lauger Date: Sun, 25 Jan 2026 23:10:45 +0100 Subject: [PATCH 05/10] Fix RuboCop violations - Combine duplicate branches for Integer/Float/TrueClass/FalseClass - Use %w[] notation for word arrays in tests - All tests still pass with the same behavior --- lib/openvox-strings/hiera.rb | 7 ++----- spec/unit/openvox-strings/hiera_spec.rb | 6 +++--- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/lib/openvox-strings/hiera.rb b/lib/openvox-strings/hiera.rb index e3d5ed13..2c780b1e 100644 --- a/lib/openvox-strings/hiera.rb +++ b/lib/openvox-strings/hiera.rb @@ -85,11 +85,8 @@ def value_to_puppet_string(value) # Strings should be quoted "'#{value}'" - when Integer, Float - # Numbers are unquoted - value.to_s - when TrueClass, FalseClass - # Booleans are lowercase + when Integer, Float, TrueClass, FalseClass + # Numbers and booleans are unquoted/lowercase value.to_s when NilClass, :undef # Puppet undef diff --git a/spec/unit/openvox-strings/hiera_spec.rb b/spec/unit/openvox-strings/hiera_spec.rb index 561aec60..fcd90068 100644 --- a/spec/unit/openvox-strings/hiera_spec.rb +++ b/spec/unit/openvox-strings/hiera_spec.rb @@ -180,7 +180,7 @@ end it 'converts arrays with values' do - expect(hiera.send(:value_to_puppet_string, ['a', 'b'])).to eq("['a', 'b']") + expect(hiera.send(:value_to_puppet_string, %w[a b])).to eq("['a', 'b']") end it 'converts empty hashes' do @@ -201,7 +201,7 @@ it 'formats arrays without spaces to match code defaults' do # Arrays must use ['a', 'b'] not [ 'a', 'b' ] to match puppet-strings code format - result = hiera.send(:value_to_puppet_string, ['alpha', 'beta', 'gamma']) + result = hiera.send(:value_to_puppet_string, %w[alpha beta gamma]) expect(result).to eq("['alpha', 'beta', 'gamma']") expect(result).not_to include('[ ') expect(result).not_to include(' ]') @@ -215,7 +215,7 @@ end it 'formats nested arrays in hashes without spaces' do - nested = { 'tags' => ['prod', 'eu'] } + nested = { 'tags' => %w[prod eu] } result = hiera.send(:value_to_puppet_string, nested) expect(result).to eq("{ 'tags' => ['prod', 'eu'] }") expect(result).not_to include('[ ') From 878eff23f4c6e46114fdbd244ca840d7234d8ef5 Mon Sep 17 00:00:00 2001 From: Simon Lauger <8333300+slauger@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:53:06 +0100 Subject: [PATCH 06/10] fix: update lib/openvox-strings/markdown/base.rb use Pathname to check file/folder existence - metadata.json, manifests, hiera.yaml Co-authored-by: Ewoud Kohl van Wijngaarden --- lib/openvox-strings/markdown/base.rb | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/lib/openvox-strings/markdown/base.rb b/lib/openvox-strings/markdown/base.rb index f003d5b3..d44a6b21 100644 --- a/lib/openvox-strings/markdown/base.rb +++ b/lib/openvox-strings/markdown/base.rb @@ -244,23 +244,12 @@ def initialize_hiera # @param [String] file_path The path to a file in the module # @return [String, nil] The module root path or nil if not found def find_module_root(file_path) - current_path = File.dirname(File.expand_path(file_path)) - - # Walk up the directory tree looking for module indicators - 10.times do - # Check if this looks like a module root (has manifests/, lib/, or hiera.yaml) - if File.exist?(File.join(current_path, 'hiera.yaml')) || - (File.exist?(File.join(current_path, 'manifests')) && File.exist?(File.join(current_path, 'metadata.json'))) - return current_path - end - - parent = File.dirname(current_path) - break if parent == current_path # Reached filesystem root - - current_path = parent + names = %w[metadata.json manifests hiera.yaml] + Pathname.new(file_path).expand_path.parent.ascend do |current_path| + names.each do |name| + return current_path if (current_path / name).exist? end - - nil + end end # Merges code defaults with Hiera defaults From 86e6eae3ce8c9ada106ca44e700595310bee089d Mon Sep 17 00:00:00 2001 From: Simon Lauger Date: Mon, 26 Jan 2026 16:04:19 +0100 Subject: [PATCH 07/10] Improve Hiera hierarchy detection and fix indentation - Use dynamic hierarchy parsing instead of hardcoded common.yaml - Find first hierarchy layer without interpolations (%{...}) - Support various file names (common.yaml, common.yml, defaults.yaml) - Fix RuboCop indentation violation in find_module_root method --- lib/openvox-strings/hiera.rb | 31 ++++++++++++++++++++++------ lib/openvox-strings/markdown/base.rb | 10 ++++----- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/lib/openvox-strings/hiera.rb b/lib/openvox-strings/hiera.rb index 2c780b1e..cf5e2507 100644 --- a/lib/openvox-strings/hiera.rb +++ b/lib/openvox-strings/hiera.rb @@ -55,21 +55,40 @@ def load_hiera_config end end - # Loads and parses data/common.yaml from the module - # @return [Hash, nil] The parsed common data, or nil if not found/invalid + # Loads and parses the first static hierarchy layer (without interpolations) + # @return [Hash, nil] The parsed data, or nil if not found/invalid def load_common_data return nil unless hiera_enabled? # Get datadir from hiera config (defaults to 'data') datadir = @hiera_config.dig('defaults', 'datadir') || 'data' - common_file = File.join(@module_path, datadir, 'common.yaml') - return nil unless File.exist?(common_file) + # Find first hierarchy entry without interpolations + hierarchy = @hiera_config['hierarchy'] + return nil unless hierarchy + + first_static = hierarchy.find do |entry| + path = entry['path'] || entry['paths']&.first + next false unless path + + # Check if path contains interpolations like %{...} + !path.match?(/%\{[^}]+\}/) + end + + return nil unless first_static + + # Get the path from the hierarchy entry + data_file_path = first_static['path'] || first_static['paths']&.first + return nil unless data_file_path + + # Build full path + data_file = File.join(@module_path, datadir, data_file_path) + return nil unless File.exist?(data_file) begin - YAML.load_file(common_file) + YAML.load_file(data_file) rescue StandardError => e - YARD::Logger.instance.warn "Failed to parse common.yaml: #{e.message}" + YARD::Logger.instance.warn "Failed to parse #{data_file_path}: #{e.message}" nil end end diff --git a/lib/openvox-strings/markdown/base.rb b/lib/openvox-strings/markdown/base.rb index d44a6b21..4f00afd1 100644 --- a/lib/openvox-strings/markdown/base.rb +++ b/lib/openvox-strings/markdown/base.rb @@ -244,13 +244,13 @@ def initialize_hiera # @param [String] file_path The path to a file in the module # @return [String, nil] The module root path or nil if not found def find_module_root(file_path) - names = %w[metadata.json manifests hiera.yaml] - Pathname.new(file_path).expand_path.parent.ascend do |current_path| - names.each do |name| - return current_path if (current_path / name).exist? + names = %w[metadata.json manifests hiera.yaml] + Pathname.new(file_path).expand_path.parent.ascend do |current_path| + names.each do |name| + return current_path if (current_path / name).exist? + end end end - end # Merges code defaults with Hiera defaults # @param [Hash] code_defaults The defaults from the Puppet code From d99d0d003bc6e141a0f65b6d9a9f89f186995668 Mon Sep 17 00:00:00 2001 From: Simon Lauger Date: Tue, 27 Jan 2026 11:49:49 +0100 Subject: [PATCH 08/10] Refactor load_common_data into smaller testable methods Split load_common_data into two focused methods for better testability: - find_first_static_layer_path: Finds the first static hierarchy layer - load_yaml_data: Loads and parses YAML data files Added comprehensive unit tests for both new methods covering edge cases like interpolated paths, custom datadirs, and invalid YAML files. --- lib/openvox-strings/hiera.rb | 31 +++-- spec/unit/openvox-strings/hiera_spec.rb | 171 ++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 8 deletions(-) diff --git a/lib/openvox-strings/hiera.rb b/lib/openvox-strings/hiera.rb index cf5e2507..8f0f446d 100644 --- a/lib/openvox-strings/hiera.rb +++ b/lib/openvox-strings/hiera.rb @@ -55,9 +55,9 @@ def load_hiera_config end end - # Loads and parses the first static hierarchy layer (without interpolations) - # @return [Hash, nil] The parsed data, or nil if not found/invalid - def load_common_data + # Finds the path to the first static hierarchy layer (without interpolations) + # @return [String, nil] The full path to the data file, or nil if not found + def find_first_static_layer_path return nil unless hiera_enabled? # Get datadir from hiera config (defaults to 'data') @@ -81,18 +81,33 @@ def load_common_data data_file_path = first_static['path'] || first_static['paths']&.first return nil unless data_file_path - # Build full path - data_file = File.join(@module_path, datadir, data_file_path) - return nil unless File.exist?(data_file) + # Build and return full path + File.join(@module_path, datadir, data_file_path) + end + + # Loads and parses a YAML data file + # @param [String] file_path The full path to the YAML file to load + # @return [Hash, nil] The parsed YAML data, or nil if not found/invalid + def load_yaml_data(file_path) + return nil unless File.exist?(file_path) begin - YAML.load_file(data_file) + YAML.load_file(file_path) rescue StandardError => e - YARD::Logger.instance.warn "Failed to parse #{data_file_path}: #{e.message}" + YARD::Logger.instance.warn "Failed to parse #{File.basename(file_path)}: #{e.message}" nil end end + # Loads and parses the first static hierarchy layer (without interpolations) + # @return [Hash, nil] The parsed data, or nil if not found/invalid + def load_common_data + data_file = find_first_static_layer_path + return nil unless data_file + + load_yaml_data(data_file) + end + # Converts a Ruby value to a Puppet-compatible string representation # @param [Object] value The value to convert # @return [String] The Puppet-compatible string representation diff --git a/spec/unit/openvox-strings/hiera_spec.rb b/spec/unit/openvox-strings/hiera_spec.rb index fcd90068..15b68d5d 100644 --- a/spec/unit/openvox-strings/hiera_spec.rb +++ b/spec/unit/openvox-strings/hiera_spec.rb @@ -46,6 +46,177 @@ end end + describe '#find_first_static_layer_path' do + context 'when a static layer exists' do + before do + File.write(File.join(temp_dir, 'hiera.yaml'), <<~YAML) + version: 5 + defaults: + datadir: data + data_hash: yaml_data + hierarchy: + - name: "Common defaults" + path: "common.yaml" + YAML + end + + it 'returns the full path to the static layer' do + hiera = described_class.new(temp_dir) + result = hiera.send(:find_first_static_layer_path) + expect(result).to eq(File.join(temp_dir, 'data', 'common.yaml')) + end + end + + context 'when hierarchy contains interpolations before static layer' do + before do + File.write(File.join(temp_dir, 'hiera.yaml'), <<~YAML) + version: 5 + defaults: + datadir: data + data_hash: yaml_data + hierarchy: + - name: "Per-node data" + path: "nodes/%{facts.hostname}.yaml" + - name: "Per-environment data" + path: "env/%{facts.environment}.yaml" + - name: "Common defaults" + path: "common.yaml" + YAML + end + + it 'skips layers with interpolations and returns first static layer' do + hiera = described_class.new(temp_dir) + result = hiera.send(:find_first_static_layer_path) + expect(result).to eq(File.join(temp_dir, 'data', 'common.yaml')) + end + end + + context 'when only interpolated layers exist' do + before do + File.write(File.join(temp_dir, 'hiera.yaml'), <<~YAML) + version: 5 + defaults: + datadir: data + data_hash: yaml_data + hierarchy: + - name: "Per-node data" + path: "nodes/%{facts.hostname}.yaml" + - name: "Per-environment data" + path: "env/%{environment}.yaml" + YAML + end + + it 'returns nil' do + hiera = described_class.new(temp_dir) + result = hiera.send(:find_first_static_layer_path) + expect(result).to be_nil + end + end + + context 'when using custom datadir' do + before do + File.write(File.join(temp_dir, 'hiera.yaml'), <<~YAML) + version: 5 + defaults: + datadir: hieradata + data_hash: yaml_data + hierarchy: + - name: "Common" + path: "common.yaml" + YAML + end + + it 'uses the custom datadir' do + hiera = described_class.new(temp_dir) + result = hiera.send(:find_first_static_layer_path) + expect(result).to eq(File.join(temp_dir, 'hieradata', 'common.yaml')) + end + end + + context 'when hierarchy uses paths instead of path' do + before do + File.write(File.join(temp_dir, 'hiera.yaml'), <<~YAML) + version: 5 + defaults: + datadir: data + data_hash: yaml_data + hierarchy: + - name: "Multiple paths" + paths: + - "first.yaml" + - "second.yaml" + YAML + end + + it 'uses the first path from paths array' do + hiera = described_class.new(temp_dir) + result = hiera.send(:find_first_static_layer_path) + expect(result).to eq(File.join(temp_dir, 'data', 'first.yaml')) + end + end + + context 'when hierarchy is empty' do + before do + File.write(File.join(temp_dir, 'hiera.yaml'), <<~YAML) + version: 5 + defaults: + datadir: data + data_hash: yaml_data + hierarchy: [] + YAML + end + + it 'returns nil' do + hiera = described_class.new(temp_dir) + result = hiera.send(:find_first_static_layer_path) + expect(result).to be_nil + end + end + end + + describe '#load_yaml_data' do + context 'when file exists and is valid YAML' do + before do + FileUtils.mkdir_p(File.join(temp_dir, 'data')) + File.write(File.join(temp_dir, 'data', 'test.yaml'), <<~YAML) + key1: value1 + key2: 42 + YAML + end + + it 'loads and parses the YAML file' do + hiera = described_class.new(temp_dir) + file_path = File.join(temp_dir, 'data', 'test.yaml') + result = hiera.send(:load_yaml_data, file_path) + expect(result).not_to be_nil + expect(result['key1']).to eq('value1') + expect(result['key2']).to eq(42) + end + end + + context 'when file does not exist' do + it 'returns nil' do + hiera = described_class.new(temp_dir) + result = hiera.send(:load_yaml_data, File.join(temp_dir, 'nonexistent.yaml')) + expect(result).to be_nil + end + end + + context 'when file contains invalid YAML' do + before do + FileUtils.mkdir_p(File.join(temp_dir, 'data')) + File.write(File.join(temp_dir, 'data', 'invalid.yaml'), "invalid: yaml: content: [") + end + + it 'returns nil and logs warning' do + hiera = described_class.new(temp_dir) + file_path = File.join(temp_dir, 'data', 'invalid.yaml') + result = hiera.send(:load_yaml_data, file_path) + expect(result).to be_nil + end + end + end + describe '#load_common_data' do context 'when common.yaml exists' do before do From 61a1d323da4871c5d16a89893682b03930592261 Mon Sep 17 00:00:00 2001 From: Simon Lauger Date: Tue, 27 Jan 2026 12:11:21 +0100 Subject: [PATCH 09/10] Fix RuboCop violations for Hiera interpolation detection Replace regex pattern with simpler string check in production code (path.include?('%{') instead of regex) and add RuboCop disable comments for test fixtures that contain Hiera interpolation syntax. - Use .include?('%{') for cleaner interpolation detection - Add Style/FormatStringToken disable in test fixtures - Fix string literal quote style in tests --- lib/openvox-strings/hiera.rb | 2 +- spec/unit/openvox-strings/hiera_spec.rb | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/openvox-strings/hiera.rb b/lib/openvox-strings/hiera.rb index 8f0f446d..062298e1 100644 --- a/lib/openvox-strings/hiera.rb +++ b/lib/openvox-strings/hiera.rb @@ -72,7 +72,7 @@ def find_first_static_layer_path next false unless path # Check if path contains interpolations like %{...} - !path.match?(/%\{[^}]+\}/) + !path.include?('%{') end return nil unless first_static diff --git a/spec/unit/openvox-strings/hiera_spec.rb b/spec/unit/openvox-strings/hiera_spec.rb index 15b68d5d..463aa6b6 100644 --- a/spec/unit/openvox-strings/hiera_spec.rb +++ b/spec/unit/openvox-strings/hiera_spec.rb @@ -69,6 +69,7 @@ context 'when hierarchy contains interpolations before static layer' do before do + # rubocop:disable Style/FormatStringToken File.write(File.join(temp_dir, 'hiera.yaml'), <<~YAML) version: 5 defaults: @@ -82,6 +83,7 @@ - name: "Common defaults" path: "common.yaml" YAML + # rubocop:enable Style/FormatStringToken end it 'skips layers with interpolations and returns first static layer' do @@ -93,6 +95,7 @@ context 'when only interpolated layers exist' do before do + # rubocop:disable Style/FormatStringToken File.write(File.join(temp_dir, 'hiera.yaml'), <<~YAML) version: 5 defaults: @@ -104,6 +107,7 @@ - name: "Per-environment data" path: "env/%{environment}.yaml" YAML + # rubocop:enable Style/FormatStringToken end it 'returns nil' do @@ -205,7 +209,7 @@ context 'when file contains invalid YAML' do before do FileUtils.mkdir_p(File.join(temp_dir, 'data')) - File.write(File.join(temp_dir, 'data', 'invalid.yaml'), "invalid: yaml: content: [") + File.write(File.join(temp_dir, 'data', 'invalid.yaml'), 'invalid: yaml: content: [') end it 'returns nil and logs warning' do From e2152735f7807db31eb1f08c39a747888e556d3b Mon Sep 17 00:00:00 2001 From: Simon Lauger Date: Sat, 7 Feb 2026 22:25:42 +0100 Subject: [PATCH 10/10] Fix paths array handling and simplify load_hiera_config - Reuse load_yaml_data in load_hiera_config to reduce code duplication - Check all entries in paths array for interpolations, not just the first - Add test for mixed paths array scenario --- lib/openvox-strings/hiera.rb | 26 +++++++++++------------ spec/unit/openvox-strings/hiera_spec.rb | 28 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/lib/openvox-strings/hiera.rb b/lib/openvox-strings/hiera.rb index 062298e1..152098b4 100644 --- a/lib/openvox-strings/hiera.rb +++ b/lib/openvox-strings/hiera.rb @@ -45,14 +45,7 @@ def lookup_default(class_name, param_name) # @return [Hash, nil] The parsed hiera configuration, or nil if not found/invalid def load_hiera_config hiera_file = File.join(@module_path, 'hiera.yaml') - return nil unless File.exist?(hiera_file) - - begin - YAML.load_file(hiera_file) - rescue StandardError => e - YARD::Logger.instance.warn "Failed to parse hiera.yaml: #{e.message}" - nil - end + load_yaml_data(hiera_file) end # Finds the path to the first static hierarchy layer (without interpolations) @@ -68,11 +61,18 @@ def find_first_static_layer_path return nil unless hierarchy first_static = hierarchy.find do |entry| - path = entry['path'] || entry['paths']&.first - next false unless path - - # Check if path contains interpolations like %{...} - !path.include?('%{') + path_or_paths = entry['path'] || entry['paths'] + next false unless path_or_paths + + # Check if path(s) contain interpolations like %{...} + case path_or_paths + when String + !path_or_paths.include?('%{') + when Array + path_or_paths.none? { |p| p.include?('%{') } + else + false + end end return nil unless first_static diff --git a/spec/unit/openvox-strings/hiera_spec.rb b/spec/unit/openvox-strings/hiera_spec.rb index 463aa6b6..c3ab7f11 100644 --- a/spec/unit/openvox-strings/hiera_spec.rb +++ b/spec/unit/openvox-strings/hiera_spec.rb @@ -159,6 +159,34 @@ end end + context 'when hierarchy uses paths array with mixed static and interpolated entries' do + before do + # rubocop:disable Style/FormatStringToken + File.write(File.join(temp_dir, 'hiera.yaml'), <<~YAML) + version: 5 + defaults: + datadir: data + data_hash: yaml_data + hierarchy: + - name: "Mixed paths" + paths: + - "common.yaml" + - "nodes/%{facts.hostname}.yaml" + - name: "Fallback" + path: "fallback.yaml" + YAML + # rubocop:enable Style/FormatStringToken + end + + it 'skips paths array containing any interpolations and uses next static layer' do + hiera = described_class.new(temp_dir) + result = hiera.send(:find_first_static_layer_path) + # Should skip "Mixed paths" because it contains interpolated entries + # and return "fallback.yaml" instead + expect(result).to eq(File.join(temp_dir, 'data', 'fallback.yaml')) + end + end + context 'when hierarchy is empty' do before do File.write(File.join(temp_dir, 'hiera.yaml'), <<~YAML)