Class: Rubino::Config::Writer

Inherits:
Object
  • Object
show all
Defined in:
lib/rubino/config/writer.rb

Overview

Writes configuration changes back to the YAML file.

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(config_path:) ⇒ Writer

Returns a new instance of Writer.



11
12
13
# File 'lib/rubino/config/writer.rb', line 11

def initialize(config_path:)
  @config_path = config_path
end

Class Method Details

.coerce_array(value) ⇒ Object

ARRAY support for ‘config set` (#420): an array-typed key (e.g. an MCP stdio server’s ‘args`) previously had no CLI syntax — a bare string was written and the schema validator rejected it (expected array), forcing a hand-edit of config.yml. Accept an explicit JSON array literal (`config set …args ’[“run”,“server”]‘`) — unambiguous, so a legitimate scalar string is never mis-coerced. Returns the parsed Array, or nil when the value isn’t a JSON array (the caller falls back to the raw string).



100
101
102
103
104
105
106
107
108
# File 'lib/rubino/config/writer.rb', line 100

def self.coerce_array(value)
  str = value.to_s.strip
  return nil unless str.start_with?("[") && str.end_with?("]")

  parsed = JSON.parse(str)
  parsed.is_a?(Array) ? parsed : nil
rescue JSON::ParserError
  nil
end

.coerce_value(value) ⇒ Object

The string→typed coercion ‘config set` applies to a CLI-supplied value, exposed as a class method so set-time validation (Config::Validator) compares the SAME coerced type the file will actually store.



81
82
83
84
85
86
87
88
89
90
91
# File 'lib/rubino/config/writer.rb', line 81

def self.coerce_value(value)
  case value
  when "true" then true
  when "false" then false
  when "nil", "null" then nil
  when /\A\d+\z/ then value.to_i
  when /\A\d+\.\d+\z/ then value.to_f
  else
    coerce_array(value) || value
  end
end

Instance Method Details

#get(key_path) ⇒ Object

Returns the value at a dot-notation key path



111
112
113
114
115
116
117
118
119
# File 'lib/rubino/config/writer.rb', line 111

def get(key_path)
  raw = load_raw
  keys = key_path.split(".")
  # A scalar intermediate node (e.g. a String) has no #dig; treat such a
  # path as "not found" rather than crashing with a TypeError.
  raw.dig(*keys)
rescue TypeError
  nil
end

#set(key_path, value) ⇒ Object

Sets a single key (dot-notation) to a value and persists.

The whole read-modify-write runs under an exclusive lock (and the write is temp-file + atomic rename), so two concurrent ‘config set` of different keys can’t lose one another’s update or tear config.yml into unparseable YAML that bricks every later command.



21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# File 'lib/rubino/config/writer.rb', line 21

def set(key_path, value)
  Util::AtomicFile.update(@config_path) do |current|
    raw = parse_raw(current)
    keys = key_path.split(".")
    reject_scalar_over_section!(key_path, keys, raw, value)
    hash = raw

    keys[0..-2].each_with_index do |k, i|
      hash[k] ||= {}
      hash = hash[k]
      next if hash.is_a?(Hash)

      traversed = keys[0..i].join(".")
      raise ConfigurationError,
            "cannot set '#{key_path}': '#{traversed}' is a scalar value, not a section"
    end

    # Set-time schema validation (#327): runs AFTER the structural
    # scalar-over-section / scalar-intermediate checks above (so those
    # keep their more specific messages) but BEFORE the write — an unknown
    # key or a value whose type/format can't match the schema is rejected
    # with a clear ConfigurationError and a non-zero exit, instead of being
    # written with a green ✓ and only surfacing later as a runtime crash or
    # a deterministic provider 4xx the agent then retries for ~85s.
    Validator.validate!(key_path, keys, value)

    hash[keys.last] = self.class.coerce_value(value)
    raw.to_yaml
  end
end

#unset(key_path) ⇒ Object

Removes a single key (dot-notation) from config.yml so a user can DROP a setting and fall back to the built-in default (F2). Same locked, atomic-rename write as #set. Returns true when the key existed and was removed, false when it was already absent (a no-op — never an error, and the file is left untouched). A scalar intermediate on the path (e.g. ‘unset foo.bar` where `foo` is a scalar) is “not present” → false, not a crash.



59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/rubino/config/writer.rb', line 59

def unset(key_path)
  removed = false
  Util::AtomicFile.update(@config_path) do |current|
    raw = parse_raw(current)
    keys = key_path.split(".")
    parent = keys[0..-2].reduce(raw) do |node, k|
      node.is_a?(Hash) && node[k].is_a?(Hash) ? node[k] : (break nil)
    end
    if parent.is_a?(Hash) && parent.key?(keys.last)
      parent.delete(keys.last)
      removed = true
      raw.to_yaml
    end
    # Key absent / unreachable path: the if returns nil, so AtomicFile
    # skips the write entirely — a true no-op, file untouched.
  end
  removed
end