Verified Commit fb9536e1 authored by Hercules Merscher's avatar Hercules Merscher 🌴
Browse files

feat: Loading experiences on-demand

parent 7549a92d
Loading
Loading
Loading
Loading
+1 −1
Original line number Diff line number Diff line
@@ -12,7 +12,7 @@ module Labkit
    class << self
      extend Forwardable

      def_delegators :registry, :[], :invalid_definitions
      def_delegators :registry, :[]

      def registry
        @registry ||= Registry.new
+8 −0
Original line number Diff line number Diff line
# frozen_string_literal: true

module Labkit
  module CoveredExperience
    class Experience < Data.define(:description, :feature_category, :urgency)
    end
  end
end
+50 −43
Original line number Diff line number Diff line
# frozen_string_literal: true

require 'yaml'
require 'pathname'
require 'json-schema'
require 'forwardable'
require 'json-schema'
require 'pathname'
require 'yaml'
require 'labkit/logging/json_logger'
require 'labkit/covered_experience/experience'

module Labkit
  module CoveredExperience
@@ -13,53 +14,56 @@ module Labkit

      SCHEMA_PATH = File.expand_path('../../../config/covered_experiences/schema.json', __dir__)

      def_delegator :registry, :empty?
      def_delegator :@experiences, :empty?

      # @param dir [String, Pathname] Directory path containing YAML file definitions
      #   Defaults to 'config/covered_experiences' relative to the calling application's root
      def initialize(dir: nil)
        @dir = dir
        @experiences = load_on_demand
        @invalid_definitions = {}
      end

      # Retrieve a definition from the registry
      # Retrieve a definition experience given a covered_experience_id.
      #
      # @param covered_experience_id [String, Symbol] Covered experience identifier
      # @return [Hash, nil] Definition hash if present, otherwise nil
      # @return [Experience, nil] An experience if present, otherwise nil
      def [](covered_experience_id)
        registry[covered_experience_id.to_s]
        @experiences[covered_experience_id.to_s]
      end

      # Retrieve invalid definitions (ignored because they didn't pass the schema validation)
      # Retrieve invalid definitions (ignored because they didn't pass schema validation)
      #
      # @return [Hash] Invalid definitions keyed by covered_experience_id
      # @return [Hash] Invalid validation messages keyed by covered_experience_id
      def invalid_definitions
        registry && @invalid_definitions
        @invalid_definitions.dup.freeze
      end

      private

      # Lazy-load the registry of covered experience definitions.
      # This will load all YAML files from the default directory,
      # on first access and memoize the registry for subsequent access.
      #
      # If .init_registry is called with a dir, it will clear the memoized
      # registry and load the new directory's definitions.
      # Initialize a hash that loads experiences on-demand
      #
      # @return [Hash] The memoized registry of covered experience definitions
      def registry
        @registry ||= load_definitions
      # @return [Hash] Hash with lazy loading behavior
      def load_on_demand
        unless directory_exists?(directory_path)
          warn("Directory not readable: #{directory_path}")
          return {}
        end

      # Initialize the registry by loading YAML files from the specified directory
      def load_definitions
        @registry = {}
        @invalid_definitions = {}

        warn("Directory not readable: #{directory_path}") unless directory_exists?(directory_path)

        load_yaml_files(directory_path)
        Hash.new do |result, experience_id|
          experience = load_experience(experience_id.to_s)
          # we also store nil to memoize the value and avoid triggering load_experience again
          result[experience_id.to_s] = experience
        end
      end

        @registry.freeze
      # Load a covered experience definition.
      #
      # @param experience_id [String] Experience identifier
      # @return [Experience, nil] Loaded experience or nil if not found/invalid
      def load_experience(experience_id)
        file_path = experience_definitions[experience_id]
        read_experience(file_path, experience_id) if file_path
      end

      # Resolve the directory path, using default if @dir not provided.
@@ -75,28 +79,31 @@ module Labkit
        directory_path.exist? && directory_path.directory? && directory_path.readable?
      end

      def load_yaml_files(directory_path)
        directory_path
          .glob('*.yml')
          .each { |f| load_yaml_file(f) }
      def experience_definitions
        @experience_definitions ||=
          directory_path.glob('*.yml').each_with_object({}) do |file_path, hash|
            hash[file_path.basename('.yml').to_s] = file_path
          end
      end

      def load_yaml_file(file_path)
      # Read and validate a definition experience file
      #
      # @param file_path [Pathname] Path to the definition file
      # @param experience_id [String] Expected experience ID
      # @return [Experience, nil] Parsed experience or nil if invalid
      def read_experience(file_path, experience_id)
        content = YAML.safe_load(file_path.read)
        return nil unless content.is_a?(Hash)

        return unless content.is_a?(Hash)

        covered_experience_id = file_path.basename('.yml').to_s
        errors = JSON::Validator.fully_validate(schema, content)
        return Experience.new(**content) if errors.empty?

        if errors.empty?
          @registry[covered_experience_id] = content.dup.freeze
        else
          @invalid_definitions[covered_experience_id] = errors.join(', ')
        @invalid_definitions[experience_id] = errors.join(', ')
        warn("Invalid schema for #{file_path}")
        end

        nil
      rescue Psych::SyntaxError => e
        warn("Invalid YAML file #{file_path}: #{e.message}")
        warn("Invalid definition file #{file_path}: #{e.message}")
      rescue StandardError => e
        warn("Unexpected error processing #{file_path}: #{e.message}")
      end
@@ -106,7 +113,7 @@ module Labkit
      end

      def warn(message)
        logger.warn(component: to_s, message: message)
        logger.warn(component: self.class.name, message: message)
      end

      def logger
+14 −9
Original line number Diff line number Diff line
@@ -4,6 +4,7 @@ require 'spec_helper'
require 'tmpdir'
require 'fileutils'
require 'labkit/covered_experience/registry'
require 'labkit/covered_experience/experience'

RSpec.describe Labkit::CoveredExperience::Registry do
  let(:temp_dir) { Dir.mktmpdir }
@@ -43,17 +44,18 @@ RSpec.describe Labkit::CoveredExperience::Registry do
      it 'loads all YAML files and returns registry' do
        registry = described_class.new(dir: covered_experiences_dir)

        expect(registry[:merge_request_creation]).to be_a(Hash)
        expect(registry[:issue_creation]).to be_a(Hash)
        expect(registry[:merge_request_creation]).to be_a(Labkit::CoveredExperience::Experience)
        expect(registry[:issue_creation]).to be_a(Labkit::CoveredExperience::Experience)
      end

      it 'stores complete covered experience data' do
        registry = described_class.new(dir: covered_experiences_dir)

        mr_experience = registry['merge_request_creation']
        expect(mr_experience['description']).to eq('Creating a new merge request in a project')
        expect(mr_experience['feature_category']).to eq('source_code_management')
        expect(mr_experience['urgency']).to eq('sync_fast')
        expect(registry['merge_request_creation'].to_h).to match({
          description: 'Creating a new merge request in a project',
          feature_category: 'source_code_management',
          urgency: 'sync_fast'
        })
      end

      it 'freezes the stored data' do
@@ -70,7 +72,7 @@ RSpec.describe Labkit::CoveredExperience::Registry do
        registry = described_class.new(dir: covered_experiences_dir)

        expect(registry[:yaml_test]).to be_nil
        expect(registry[:yml_test]).to be_a(Hash)
        expect(registry[:yml_test]).to be_a(Labkit::CoveredExperience::Experience)
      end
    end

@@ -88,7 +90,7 @@ RSpec.describe Labkit::CoveredExperience::Registry do
      it 'expands relative paths from current working directory' do
        registry = described_class.new(dir: relative_dir)

        expect(registry[:merge_request_creation]).to be_a(Hash)
        expect(registry[:merge_request_creation]).to be_a(Labkit::CoveredExperience::Experience)
      end
    end

@@ -98,7 +100,7 @@ RSpec.describe Labkit::CoveredExperience::Registry do

        registry = described_class.new(dir: covered_experiences_dir)

        expect(registry[:absolute_test]).to be_a(Hash)
        expect(registry[:absolute_test]).to be_a(Labkit::CoveredExperience::Experience)
      end
    end

@@ -131,6 +133,9 @@ RSpec.describe Labkit::CoveredExperience::Registry do
        registry = described_class.new(dir: covered_experiences_dir)

        expect(registry).to be_empty

        # we need to force access the invalid schema to make it populate invalid_definitions
        expect(registry['invalid_schema']).to be_nil
        expect(registry.invalid_definitions).to match("invalid_schema" => String)
      end
    end