Class: Kettle::Jem::Appraisals::ConfigFile

Inherits:
Object
  • Object
show all
Defined in:
lib/kettle/jem/appraisals/config_file.rb,
sig/kettle/jem/appraisals.rbs

Overview

Reads and surgically updates the project's kettle-jem configuration file.

The appraisal_matrix lives in the same file kettle-jem uses (+.structuredmerge/kettle-jem.yml+). That file is heavily commented and holds many other settings, so writes only replace the top-level sections whose values changed, leaving comments and every other section intact.

Examples:

config_file = ConfigFile.new(project_dir: "/path/to/gem")
config = config_file.load
config["appraisal_matrix"]["resolved_at"] = Time.now.to_i
config_file.write(config)

Constant Summary collapse

CANONICAL_PATH =

Returns canonical config path, relative to the project root.

Returns:

  • (String) —

    canonical config path, relative to the project root

Kettle::Jem::KETTLE_CONFIG_PATH
LEGACY_PATH =

Returns legacy config path, read only when the canonical file is absent.

Returns:

  • (String) —

    legacy config path, read only when the canonical file is absent

Kettle::Jem::LEGACY_KETTLE_CONFIG_PATH

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(project_dir:) ⇒ ConfigFile

Returns a new instance of ConfigFile.

Parameters:

  • project_dir (String) —

    path to the project root

  • project_dir: (String)


32
33
34
# File 'lib/kettle/jem/appraisals/config_file.rb', line 32

def initialize(project_dir:)
  @project_dir = project_dir
end

Instance Attribute Details

#project_dir ⇒ String (readonly)

Returns absolute path to the project root.

Returns:

  • (String) —

    absolute path to the project root



29
30
31
# File 'lib/kettle/jem/appraisals/config_file.rb', line 29

def project_dir
  @project_dir
end

Instance Method Details

#load ⇒ Hash

Returns the parsed config, or an empty Hash when the file does not exist.

Returns:

  • (Hash) —

    the parsed config, or an empty Hash when the file does not exist



56
57
58
59
60
# File 'lib/kettle/jem/appraisals/config_file.rb', line 56

def load
  return {} unless File.exist?(path)

  YAML.safe_load_file(path, permitted_classes: [Symbol]) || {}
end

#path ⇒ String

The config file to read and write: the canonical file when present, otherwise an existing legacy file, otherwise the canonical location.

Returns:

  • (String) —

    absolute path



40
41
42
43
44
45
46
47
48
# File 'lib/kettle/jem/appraisals/config_file.rb', line 40

def path
  canonical = File.join(project_dir, CANONICAL_PATH)
  return canonical if File.exist?(canonical)

  legacy = File.join(project_dir, LEGACY_PATH)
  return legacy if File.exist?(legacy)

  canonical
end

#relative_path ⇒ String

Returns #path relative to the project root, for messages.

Returns:

  • (String) —

    #path relative to the project root, for messages



51
52
53
# File 'lib/kettle/jem/appraisals/config_file.rb', line 51

def relative_path
  File.expand_path(path).delete_prefix("#{File.expand_path(project_dir)}/")
end

#section_range(content, key) ⇒ Range[Integer]?

Line range (0-based, end-exclusive) of the top-level key section. A parsed section runs up to the next top-level key, so trailing blank lines and comment lines (commented-out settings, or comments that introduce the next section) are trimmed back out of the range and kept.

Parameters:

  • content (String)
  • key (String)

Returns:

  • (Range[Integer], nil)

Raises:

  • (ArgumentError)


100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/kettle/jem/appraisals/config_file.rb', line 100

def section_range(content, key)
  require "yaml/merge"

  analysis = Yaml::Merge::FileAnalysis.new(content)
  raise ArgumentError, "could not parse #{relative_path} as YAML" unless analysis.valid?

  body = analysis.documents.first&.body_node
  return unless body&.mapping?

  lines = content.lines
  pairs = body.mapping_pairs
  pairs.each_with_index do |pair, index|
    next unless pair.key_name == key

    start_index = pair.start_line.to_i - 1
    next_pair = pairs[index + 1]
    end_index = next_pair ? next_pair.start_line.to_i - 1 : lines.length
    end_index -= 1 while end_index > start_index + 1 && trailing_trivia?(lines[end_index - 1])
    return start_index...end_index
  end

  nil
end

#trailing_trivia?(line) ⇒ Boolean

Returns whether line is blank or a comment (at any indentation).

Parameters:

  • line (String)

Returns:

  • (Boolean) —

    whether line is blank or a comment (at any indentation)



125
126
127
128
# File 'lib/kettle/jem/appraisals/config_file.rb', line 125

def trailing_trivia?(line)
  stripped = line.strip
  stripped.empty? || stripped.start_with?("#")
end

#upsert_section(content, key, value) ⇒ String

Parameters:

  • content (String)
  • key (String)
  • value (Object)

Returns:

  • (String)


87
88
89
90
91
92
93
94
# File 'lib/kettle/jem/appraisals/config_file.rb', line 87

def upsert_section(content, key, value)
  block = YAML.dump({key => value}).delete_prefix("---\n")
  range = section_range(content, key)
  return "#{content.rstrip}\n\n#{block}" unless range

  lines = content.lines
  [*lines[0...range.begin], block, *lines[range.end..]].join
end

#write(config) ⇒ void

This method returns an undefined value.

Writes config, replacing only the top-level sections that differ from the file on disk. Creates the file (and its directory) when missing.

Parameters:

  • config (Hash) —

    the full desired config



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/kettle/jem/appraisals/config_file.rb', line 67

def write(config)
  target = path
  unless File.exist?(target)
    FileUtils.mkdir_p(File.dirname(target))
    File.write(target, YAML.dump(config))
    return
  end

  content = File.read(target)
  current = YAML.safe_load(content, permitted_classes: [Symbol]) || {}
  config.each do |key, value|
    next if current.key?(key) && current[key] == value

    content = upsert_section(content, key.to_s, value)
  end
  File.write(target, content)
end