Class: RepoTender::Config::Store

Inherits:
Object
  • Object
show all
Defined in:
lib/repo_tender/config/store.rb

Overview

Load/validate/write-back the YAML config file.

The store is the only place that touches the disk. It does not preserve unknown keys or YAML comments on write — that’s a known limitation per the PRD §2 (YAML comment loss accepted) and is documented in the Slice 1 lane report. Managed fields round-trip byte-identically (gate G1).

Constant Summary collapse

DEFAULT_BASE_DIR =
RepoTender::Paths::DEFAULT_BASE_DIR
DEFAULT_REFRESH_INTERVAL =
6 * 3600
DEFAULT_CONCURRENCY =
8

Class Method Summary collapse

Class Method Details

.defaultsObject



123
124
125
126
127
128
129
130
131
# File 'lib/repo_tender/config/store.rb', line 123

def self.defaults
  {
    base_dir: DEFAULT_BASE_DIR,
    refresh_interval: DEFAULT_REFRESH_INTERVAL,
    concurrency: DEFAULT_CONCURRENCY,
    repos: [],
    orgs: []
  }
end

.emit(hash) ⇒ Object

Hash → YAML string. Stable key order for diff-ability.



83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/repo_tender/config/store.rb', line 83

def self.emit(hash)
  # Order: base_dir, refresh_interval, concurrency, repos, orgs.
  ordered = {}
  ordered[:base_dir] = hash[:base_dir] if hash.key?(:base_dir)
  ordered[:refresh_interval] = hash[:refresh_interval] if hash.key?(:refresh_interval)
  ordered[:concurrency] = hash[:concurrency] if hash.key?(:concurrency)
  ordered[:repos] = hash[:repos] if hash.key?(:repos) && !hash[:repos].nil?
  ordered[:orgs] = hash[:orgs] if hash.key?(:orgs) && !hash[:orgs].nil?

  # Ruby's Psych has a default flow style and key order that is
  # good enough — we don't customize it.
  YAML.dump(ordered, line_width: -1)
end

.load(path) ⇒ Object



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
51
52
53
# File 'lib/repo_tender/config/store.rb', line 25

def self.load(path)
  raw = read_yaml(path)
  hash = symbolize(raw)
  # CF1: normalize `refresh_interval` from a human-duration
  # string ("6h", "90m", "45s", "30d") or bare integer string
  # ("21600") into integer seconds BEFORE the contract runs.
  # The contract stays integer-typed (:integer, gt?: 0); this
  # is a load-layer normalization that lets a hand-edited
  # config.yaml round-trip without rejecting "6h" as a
  # non-integer. See lib/repo_tender/config/duration.rb.
  if hash.key?(:refresh_interval)
    result = Duration.parse(hash[:refresh_interval])
    return result if result.failure?
    hash[:refresh_interval] = result.success
  end
  with_defaults(hash) do |filled|
    result = Contract.new.call(filled)
    if result.success?
      Success(Config.new(result.success))
    else
      Failure(result.failure)
    end
  end
rescue Errno::ENOENT
  # Missing file is treated as an empty config (load defaults).
  # The store does not create the file on read — that is the
  # writer's job (write() is idempotent and always validates first).
  Success(Config.new(defaults))
end

.read_yaml(path) ⇒ Object



97
98
99
100
101
102
103
# File 'lib/repo_tender/config/store.rb', line 97

def self.read_yaml(path)
  return {} unless File.exist?(path)
  # Symbol permitted because some YAML files use :symbol keys; the
  # store's symbolize() then re-keys consistently anyway. We still
  # disallow arbitrary classes.
  YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false) || {}
end

.symbolize(value) ⇒ Object



105
106
107
108
109
110
111
112
113
114
# File 'lib/repo_tender/config/store.rb', line 105

def self.symbolize(value)
  case value
  when Hash
    value.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = symbolize(v) }
  when Array
    value.map { |v| symbolize(v) }
  else
    value
  end
end

.update(path) ⇒ Object



69
70
71
72
73
# File 'lib/repo_tender/config/store.rb', line 69

def self.update(path)
  config = load(path).success
  new_config = yield(config)
  write(path, new_config)
end

.with(config, **changes) ⇒ Object

dry-struct update idiom: pass a hash of attributes to ‘Config#new` to get a new struct with the overrides applied (existing fields are kept).



78
79
80
# File 'lib/repo_tender/config/store.rb', line 78

def self.with(config, **changes)
  config.new(**changes)
end

.with_defaults(hash) {|filled| ... } ⇒ Object

Fill in missing top-level defaults before validation, so an empty YAML produces a fully-populated Config.

Yields:

  • (filled)


118
119
120
121
# File 'lib/repo_tender/config/store.rb', line 118

def self.with_defaults(hash)
  filled = defaults.merge(hash)
  yield(filled)
end

.write(path, config) ⇒ Object



55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/repo_tender/config/store.rb', line 55

def self.write(path, config)
  # Always re-validate the struct's contents before writing. This
  # guards against a caller constructing a Config with a
  # constraint-violating value via Struct.new (which does not run
  # the same checks as the contract).
  hash = config.to_h
  result = Contract.new.call(hash)
  return result if result.failure?

  FileUtils.mkdir_p(File.dirname(path))
  File.write(path, emit(hash))
  Success(config)
end