railbow 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 783c8ab9f75f889cd1c54cd36dbc258e06b411b9ae5132af6815564f83d73312
4
+ data.tar.gz: 1200e9edbd432786044d0ac0992773de513390e787500eecd48dc745a473da4e
5
+ SHA512:
6
+ metadata.gz: be4c900efd96d11e1d896eb0facd145a928fc93b2b08d30b02cf14a3638e0cf5f133f0c929d037cf828cb3862387ad63725cbbc1cdad4fb84afa353b77ef32fe
7
+ data.tar.gz: 5231639b5beaefce4ee54e9d0e81b0309d8370b66e4cb7ca9a0e496ad64d0bebcd8b8a69458fe056789947a216e6b6cbdd3c8ad0fb442ddfebf38d25cbf196b8
data/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-07-08
9
+
10
+ First public release.
11
+
12
+ ### Added
13
+
14
+ - Colorful `db:migrate` / `db:migrate:down` output with readable millisecond
15
+ timing.
16
+ - Rich `db:migrate:status` dashboard: calendar view with month/week
17
+ separators, status aliases, created-at timestamps, landing-date badges,
18
+ branch badges, affected-table detection, time filtering, and highlighting
19
+ of your own migrations.
20
+ - Git integration: authors, diffs, branch origin, landing dates, and
21
+ uncommitted-file indicators.
22
+ - Ghost migration recovery in `db:migrate:status` via the optional
23
+ [mighost](https://github.com/amberpixels/mighost) gem (`Mighost::API`).
24
+ - Color-coded `rails routes`, `rails stats` tables, `rails notes` with git
25
+ blame, and polished `rails about`.
26
+ - Layered configuration: built-in defaults, `~/.config/railbow/config.yml`,
27
+ `.railbow.yml`, `.railbow.local.yml`, and `RBW_*` environment variables;
28
+ interactive `railbow init` / `rake railbow:init` generator.
29
+ - `railbow` CLI wrapper to use Railbow without touching a project's Gemfile,
30
+ plus `railbow demo` showcase.
31
+ - Smart auto-disable in CI, piped output, `NO_COLOR`, and LLM agents.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Eugene
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,243 @@
1
+ <p align="center">
2
+ <img src="logo.svg" alt="Railbow" width="580">
3
+ </p>
4
+
5
+ <div align="center">
6
+
7
+ [![Gem Version](https://img.shields.io/gem/v/railbow)](https://rubygems.org/gems/railbow)
8
+ [![CI](https://github.com/amberpixels/railbow/actions/workflows/ci.yml/badge.svg)](https://github.com/amberpixels/railbow/actions/workflows/ci.yml)
9
+ [![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](LICENSE.txt)
10
+
11
+ </div>
12
+
13
+ **Make your Rails CLI output beautiful.** Railbow enhances migrations, routes, stats, notes, and more with colorful, emoji-rich, information-dense formatting.
14
+
15
+ ## Features
16
+
17
+ - **Migrations** - colorful `db:migrate` output with readable millisecond timing
18
+ - **Migration Status** - rich `db:migrate:status` with git authors, calendar view, table detection, landing dates, and time filtering
19
+ - **Routes** - color-coded HTTP verbs and highlighted parameters in `rails routes`
20
+ - **Stats** - beautiful `rails stats` tables
21
+ - **Notes** - `rails notes` with git blame, author colors, date filtering, and sorting
22
+ - **About** - polished `rails about` output
23
+ - **Git Integration** - authors, diffs, branch origin, landing dates, uncommitted file indicators
24
+ - **Calendar View** - month separators and week tick markers for migration timelines
25
+ - **Ghost Recovery** - pairs with the optional [mighost](https://github.com/amberpixels/mighost) gem to recover names, authors, and branch badges for `NO FILE` migrations
26
+ - **Smart Defaults** - auto-disables in CI, piped output, `NO_COLOR`, and LLM agents
27
+
28
+ ## Installation
29
+
30
+ ### Option A: Add to your Gemfile (recommended)
31
+
32
+ ```ruby
33
+ gem "railbow", group: :development
34
+ ```
35
+
36
+ ```bash
37
+ bundle install
38
+ ```
39
+
40
+ ### Option B: Use the CLI wrapper (no Gemfile changes)
41
+
42
+ ```bash
43
+ gem install railbow
44
+ railbow rake db:migrate:status
45
+ railbow rails routes
46
+ ```
47
+
48
+ The CLI creates a temporary wrapper so Railbow loads automatically without modifying your project's Gemfile.
49
+
50
+ ## Usage
51
+
52
+ Railbow works automatically once installed. Every example below works with both `rails` and `rake` commands.
53
+
54
+ ### `rails db:migrate`
55
+
56
+ **Before:**
57
+ ```
58
+ == CreateProducts: migrating =================================================
59
+ -- create_table(:products)
60
+ -> 0.0028s
61
+ == CreateProducts: migrated (0.0028s) ========================================
62
+ ```
63
+
64
+ **After:**
65
+ ```
66
+ 🚀 CreateProducts: migrating...
67
+ ✓ create_table(:products) → 2.8ms
68
+ ✅ CreateProducts: migrated (2.8ms total)
69
+ ```
70
+
71
+ ### `rails db:migrate:status`
72
+
73
+ This is Railbow's flagship feature. The plain Rails output:
74
+
75
+ ```
76
+ Status Migration ID Migration Name
77
+ --------------------------------------------------
78
+ up 20260202145343 Add weight field to animals
79
+ up 20260203134528 Create vaccination records
80
+ up 20260210183902 Create pet tags
81
+ up 20260303120000 Add breed restrictions to adoption policies
82
+ up 20260313132325 Create veterinary appointments
83
+ ```
84
+
85
+ becomes a rich, information-dense dashboard (with default config):
86
+
87
+ ```
88
+ Feb 2026 W06
89
+ ↑↑ │ 20260202145343 │ 2026-02-02 14:53:43 │ Add weight field to animals ● animals
90
+ ↑↑ │ 20260203134528 │ 2026-02-03 13:45:28 │ Create vaccination records ● vaccination_records
91
+ ↑↑ │ 20260210183902 │ 2026-02-10 18:39:02 │ Create pet tags ⤻ Mar 04 ● pet_tags
92
+ ───────┼────────────────┼─────────────────────┼──────────────────────────────
93
+ Mar 2026 W10
94
+ ↑↑ │ 20260303120000 │ 2026-03-03 12:00:00 │ Add breed restrictions to… ⤻ Mar 13 ● adoption_policies
95
+ ↑↑ │ 20260313132325 │ 2026-03-13 13:23:25 │ Create veterinary appointm… ⎇ PS-142 ● veterinary_appointments
96
+ ```
97
+
98
+ Out of the box you get:
99
+
100
+ - **Calendar separators** — month + ISO week headers to orient you in time
101
+ - **Status aliases** — `↑↑` / `↓↓` instead of `up` / `down` (customizable)
102
+ - **Created At** — timestamp parsed from the migration ID
103
+ - **Landing dates** — `⤻ Mar 04` badge when a migration was merged to main after its creation
104
+ - **Branch badges** — `⎇ PS-142` showing the source branch/ticket
105
+ - **Affected tables** — color-coded table names extracted from migration files
106
+ - **Time filtering** — only the last 70 days shown by default (`since: 70d`)
107
+ - **Your migrations highlighted** — rows authored by you are visually distinct
108
+
109
+ If the [mighost](https://github.com/amberpixels/mighost) gem is installed, migrations whose files were deleted (e.g. after switching branches) show up with a 👻 status and their recovered name and origin branch instead of a bare `********** NO FILE **********` row.
110
+
111
+ ### `rails db:migrate:down`
112
+
113
+ ```
114
+ ⏪ CreateProducts: reverting...
115
+ ✓ drop_table(:products) → 1.2ms
116
+ ✅ CreateProducts: reverted (1.2ms total)
117
+ ```
118
+
119
+ ### `rails routes`
120
+
121
+ HTTP verbs are color-coded (GET=green, POST=yellow, PATCH/PUT=cyan, DELETE=red), route parameters (`:id`, `*splat`) are highlighted, and controller#action pairs stand out.
122
+
123
+ ### `rails stats`
124
+
125
+ Code statistics rendered as a colorful table with highlighted totals and code-to-test ratio.
126
+
127
+ ### `rails notes`
128
+
129
+ Annotations enriched with git blame data — author names, commit dates, and color-coded tags (TODO=yellow, FIXME=red, OPTIMIZE=cyan, HACK=red, NOTE=green).
130
+
131
+ ## Configuration
132
+
133
+ Railbow works with zero configuration, but everything is customizable.
134
+
135
+ ### Config files
136
+
137
+ Config is loaded in layers (each overrides the previous):
138
+
139
+ 1. **Built-in defaults**
140
+ 2. **Global:** `~/.config/railbow/config.yml`
141
+ 3. **Project:** `.railbow.yml` (commit to git)
142
+ 4. **Local:** `.railbow.local.yml` (gitignored, personal overrides)
143
+
144
+ Generate a config interactively:
145
+
146
+ ```bash
147
+ railbow init
148
+ # or, within a Rails project:
149
+ rake railbow:init
150
+ ```
151
+
152
+ ### Example `.railbow.yml`
153
+
154
+ ```yaml
155
+ since: 70d
156
+ date: rel
157
+ git: "author:me,diff,mask:auto"
158
+ view: "calendar,tables"
159
+ calendar: wticks
160
+
161
+ compact: "maxw:120"
162
+
163
+ aliases:
164
+ columns:
165
+ Status: Live
166
+ values:
167
+ Status:
168
+ up: "↑↑"
169
+ down: "↓↓"
170
+ ```
171
+
172
+ ### Environment variables
173
+
174
+ Every option can also be set via `RBW_*` environment variables, which override config files:
175
+
176
+ | Variable | Example | Description |
177
+ |---|---|---|
178
+ | `RBW_PLAIN` | `1` | Disable all formatting |
179
+ | `RBW_SINCE` | `2mo`, `70d`, `1y`, `all` | Filter migrations by time period |
180
+ | `RBW_DATE` | `full`, `rel`, `short`, `custom(%b %d)` | Date display format |
181
+ | `RBW_GIT` | `author:me,diff,mask:auto` | Git integration options |
182
+ | `RBW_VIEW` | `calendar,tables` | Enable calendar view and table detection |
183
+ | `RBW_CALENDAR` | `wticks` | Show week tick markers |
184
+ | `RBW_COMPACT` | `oneline,dense,noheader,maxw:80` | Compact display options |
185
+ | `RBW_VERB` | `GET,POST` | Filter routes by HTTP method |
186
+ | `RBW_SORT` | `file`, `date` | Sort order for notes |
187
+ | `RBW_HELP` | `1` | Show help messages |
188
+
189
+ ### Quick examples
190
+
191
+ ```bash
192
+ # Last 2 months, calendar view
193
+ RBW_SINCE=2mo rake db:migrate:status
194
+
195
+ # Relative dates, highlight your migrations
196
+ RBW_DATE=rel RBW_GIT=author:me rake db:migrate:status
197
+
198
+ # Full git context with table detection
199
+ RBW_GIT=author:all,diff RBW_VIEW=tables rake db:migrate:status
200
+
201
+ # Only GET routes
202
+ RBW_VERB=GET rails routes
203
+
204
+ # Notes sorted by commit date
205
+ RBW_SORT=date rails notes
206
+ ```
207
+
208
+ ## How It Works
209
+
210
+ Railbow integrates through a Rails Railtie — it prepends formatter modules onto existing Rails classes without modifying your code:
211
+
212
+ - `ActiveRecord::Migration` - migration output
213
+ - `ActiveRecord::Tasks::DatabaseTasks` - migration status
214
+ - `ActionDispatch::Routing::ConsoleFormatter::Sheet` - routes
215
+ - `Rails::Info` - about
216
+ - `Rails::SourceAnnotationExtractor` - notes
217
+
218
+ Formatting auto-disables when:
219
+ - `RBW_PLAIN=1` or `NO_COLOR` is set
220
+ - Running in CI (`CI` env var)
221
+ - Output is piped or redirected (non-TTY)
222
+ - Running inside an LLM agent (`CLAUDECODE` env var)
223
+
224
+ ## Requirements
225
+
226
+ - Ruby >= 3.1.0
227
+ - Rails >= 7.2 (including 8.x)
228
+
229
+ ## Development
230
+
231
+ ```bash
232
+ bin/setup # Install dependencies
233
+ bundle exec rake # Run tests + linting (RSpec + Standard)
234
+ bin/console # Interactive prompt
235
+ ```
236
+
237
+ ## Contributing
238
+
239
+ Bug reports and pull requests are welcome at [github.com/amberpixels/railbow](https://github.com/amberpixels/railbow).
240
+
241
+ ## License
242
+
243
+ MIT License. See [LICENSE.txt](https://github.com/amberpixels/railbow/blob/main/LICENSE.txt).
data/exe/railbow ADDED
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/railbow/version"
5
+ require_relative "../lib/railbow/logo"
6
+
7
+ module Railbow
8
+ module CLI
9
+ module_function
10
+
11
+ def run(args)
12
+ case args.first
13
+ when "--help", "-h", nil
14
+ Railbow.print_logo
15
+ puts
16
+ print_usage
17
+ exit 0
18
+ when "--version", "-v"
19
+ Railbow.print_logo
20
+ puts " v#{Railbow::VERSION}"
21
+ exit 0
22
+ when "init"
23
+ require "railbow/init"
24
+ Railbow::Init.run
25
+ exit 0
26
+ when "demo"
27
+ require_relative "../lib/railbow/demo/runner"
28
+ Railbow::Demo::Runner.run(args[1] || "status")
29
+ exit 0
30
+ end
31
+
32
+ project_gemfile = find_gemfile
33
+ unless project_gemfile
34
+ warn "Error: Could not find a Gemfile in the current directory or any parent directory."
35
+ exit 1
36
+ end
37
+
38
+ wrapper_path = create_wrapper_gemfile(project_gemfile)
39
+ symlink_lockfile(project_gemfile, wrapper_path)
40
+
41
+ env = {"BUNDLE_GEMFILE" => wrapper_path}
42
+ Kernel.exec(env, "bundle", "exec", *args)
43
+ end
44
+
45
+ def find_gemfile
46
+ dir = Dir.pwd
47
+ loop do
48
+ gemfile = File.join(dir, "Gemfile")
49
+ return gemfile if File.exist?(gemfile)
50
+
51
+ parent = File.dirname(dir)
52
+ return nil if parent == dir
53
+
54
+ dir = parent
55
+ end
56
+ end
57
+
58
+ def create_wrapper_gemfile(project_gemfile)
59
+ require "tempfile"
60
+
61
+ wrapper = Tempfile.new(["railbow-wrapper-", ".gemfile"], "/tmp")
62
+ wrapper.write(<<~RUBY)
63
+ eval_gemfile #{project_gemfile.inspect}
64
+ gem "railbow", "= #{Railbow::VERSION}"
65
+ RUBY
66
+ wrapper.close
67
+
68
+ wrapper.path
69
+ end
70
+
71
+ def symlink_lockfile(project_gemfile, wrapper_path)
72
+ lockfile = "#{project_gemfile}.lock"
73
+ return unless File.exist?(lockfile)
74
+
75
+ wrapper_lock = "#{wrapper_path}.lock"
76
+ File.symlink(lockfile, wrapper_lock)
77
+ end
78
+
79
+ def print_usage
80
+ puts <<~HELP
81
+ Usage: railbow <command> [args...]
82
+
83
+ Wraps any bundled Rails command so railbow is loaded automatically,
84
+ without modifying the project's Gemfile.
85
+
86
+ Commands:
87
+ init Generate a config file (interactive)
88
+ demo [type] See railbow in action without a Rails project
89
+ Types: status (default), migrate, routes, all
90
+
91
+ Examples:
92
+ railbow demo
93
+ railbow demo routes
94
+ railbow init
95
+ railbow rake db:migrate:status
96
+ railbow rails db:migrate:status
97
+ railbow rake db:migrate
98
+
99
+ Options:
100
+ --help, -h Show this help message
101
+ --version, -v Show version
102
+
103
+ For daily use, add railbow to your Gemfile instead:
104
+ gem "railbow", group: :development
105
+ HELP
106
+ end
107
+ end
108
+ end
109
+
110
+ Railbow::CLI.run(ARGV)
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "formatters/base"
4
+ require_relative "config"
5
+ require_relative "table"
6
+
7
+ module Railbow
8
+ module AboutFormatter
9
+ def to_s
10
+ return super if Railbow.plain?
11
+
12
+ formatter = Formatters::Base.new
13
+
14
+ columns = [
15
+ Table::Column.new(label: "Property", sticky: true),
16
+ Table::Column.new(label: "Value")
17
+ ]
18
+
19
+ rows = @@properties.map do |(name, value)|
20
+ val = value.respond_to?(:call) ? value.call : value
21
+ [formatter.bold(name), val.to_s]
22
+ end
23
+
24
+ renderer = Table::Renderer.new(
25
+ columns: columns,
26
+ theme: Table::Themes::PLAIN,
27
+ compact: Railbow::Params.compact_options,
28
+ aliases: Railbow::Config.table_aliases
29
+ )
30
+
31
+ output = +"\n"
32
+ output << renderer.render(rows)
33
+ output << "\n"
34
+ output
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railbow
4
+ # Assigns maximally-spaced colors to a set of unique labels.
5
+ #
6
+ # Instead of hashing into a fixed palette (where two labels can collide
7
+ # onto adjacent colors), this assigns colors by rank — evenly spacing
8
+ # hues around the color wheel. Two labels always get maximally different
9
+ # colors, three labels get ~120 deg apart, etc.
10
+ #
11
+ # Usage:
12
+ # assigner = Railbow::ColorAssigner.new(["Alice", "Bob", "Carol"])
13
+ # assigner.color_for("Alice") # => "\e[38;5;174m"
14
+ # assigner.code_for("Bob") # => 116 (256-color code)
15
+ #
16
+ class ColorAssigner
17
+ # Soft/muted 256-color codes arranged around the hue wheel.
18
+ # 12 stops, evenly spaced in hue, all at moderate saturation/lightness
19
+ # so they look pleasant on dark terminals.
20
+ WHEEL = [
21
+ 174, # 0° coral / muted rose
22
+ 216, # 30° peach
23
+ 180, # 60° warm sand
24
+ 150, # 120° sage green
25
+ 116, # 160° soft teal
26
+ 117, # 200° soft sky blue
27
+ 111, # 220° periwinkle
28
+ 146, # 260° lavender
29
+ 176, # 300° soft magenta
30
+ 182 # 330° dusty pink
31
+ ].freeze
32
+
33
+ def initialize(labels)
34
+ sorted = labels.uniq.sort
35
+ @mapping = {}
36
+ sorted.each_with_index do |label, idx|
37
+ wheel_idx = (idx * WHEEL.size / [sorted.size, 1].max) % WHEEL.size
38
+ @mapping[label] = WHEEL[wheel_idx]
39
+ end
40
+ end
41
+
42
+ # Returns the 256-color ANSI escape for the given label.
43
+ def color_for(label)
44
+ code = code_for(label)
45
+ "\e[38;5;#{code}m"
46
+ end
47
+
48
+ # Returns just the 256-color code integer for the given label.
49
+ def code_for(label)
50
+ @mapping[label] || WHEEL[0]
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Railbow
6
+ module Config
7
+ GEM_DEFAULTS = {
8
+ "aliases" => {
9
+ "columns" => {
10
+ "Status" => "Live"
11
+ },
12
+ "values" => {
13
+ "Status" => {"up" => "↑↑", "down" => "↓↓"}
14
+ }
15
+ },
16
+ "since" => "70d",
17
+ "git" => "author:all,diff,mask:auto",
18
+ "author_format" => "short",
19
+ "view" => "calendar,tables",
20
+ "calendar" => "wticks"
21
+ }.freeze
22
+
23
+ @root = nil
24
+ @loaded = nil
25
+
26
+ module_function
27
+
28
+ # Overridable root for config file lookup. Defaults to Rails.root or Dir.pwd.
29
+ def root
30
+ @root || ((defined?(Rails) && Rails.respond_to?(:root) && Rails.root) ? Rails.root.to_s : Dir.pwd)
31
+ end
32
+
33
+ def root=(path)
34
+ @root = path
35
+ @loaded = nil
36
+ end
37
+
38
+ def reset!
39
+ @root = nil
40
+ @loaded = nil
41
+ end
42
+
43
+ def global_dir
44
+ xdg = ENV["XDG_CONFIG_HOME"]
45
+ base = (xdg && !xdg.empty?) ? xdg : File.join(Dir.home, ".config")
46
+ File.join(base, "railbow")
47
+ end
48
+
49
+ def config_files
50
+ files = []
51
+
52
+ # 1. Global
53
+ global = File.join(global_dir, "config.yml")
54
+ files << global if File.exist?(global)
55
+
56
+ # 2. Project .railbow.yml / .railbow.yaml
57
+ %w[.railbow.yml .railbow.yaml].each do |name|
58
+ full = File.join(root, name)
59
+ if File.exist?(full)
60
+ files << full
61
+ break
62
+ end
63
+ end
64
+
65
+ # 3. Local .railbow.local.yml / .railbow.local.yaml
66
+ %w[.railbow.local.yml .railbow.local.yaml].each do |name|
67
+ full = File.join(root, name)
68
+ if File.exist?(full)
69
+ files << full
70
+ break
71
+ end
72
+ end
73
+
74
+ files
75
+ end
76
+
77
+ def load
78
+ @loaded ||= config_files.reduce(GEM_DEFAULTS.dup) { |acc, path| deep_merge(acc, read_yaml(path)) }
79
+ end
80
+
81
+ def column_aliases
82
+ load.dig("aliases", "columns") || {}
83
+ end
84
+
85
+ def value_aliases
86
+ load.dig("aliases", "values") || {}
87
+ end
88
+
89
+ def table_aliases
90
+ {columns: column_aliases, values: value_aliases}
91
+ end
92
+
93
+ def read_yaml(path)
94
+ content = begin
95
+ YAML.safe_load_file(path)
96
+ rescue Psych::SyntaxError, Psych::DisallowedClass
97
+ warn " Warning: failed to parse #{path}, using defaults"
98
+ {}
99
+ end
100
+ content.is_a?(Hash) ? content : {}
101
+ end
102
+
103
+ def deep_merge(base, override)
104
+ base.merge(override) do |_key, old_val, new_val|
105
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
106
+ deep_merge(old_val, new_val)
107
+ else
108
+ new_val
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end