Module: Kettle::Dev::TemplateHelpers

Defined in:
lib/kettle/dev/template_helpers.rb

Overview

Helpers shared by kettle:dev Rake tasks for templating and file ops.

Constant Summary collapse

@@template_results =

Track results of templating actions across a single process run.
Keys: absolute destination paths (String)
Values: Hash with keys: :action (Symbol, one of :create, :replace, :skip, :dir_create, :dir_replace), :timestamp (Time)

{}

Class Method Summary collapse

Class Method Details

.apply_common_replacements(content, org:, gem_name:, namespace:, namespace_shield:, gem_shield:) ⇒ String

Apply common token replacements used when templating text files

Parameters:

  • content (String)
  • org (String, nil)
  • gem_name (String)
  • namespace (String)
  • namespace_shield (String)
  • gem_shield (String)

Returns:

  • (String)


245
246
247
248
249
250
251
252
253
254
255
256
257
# File 'lib/kettle/dev/template_helpers.rb', line 245

def apply_common_replacements(content, org:, gem_name:, namespace:, namespace_shield:, gem_shield:)
  c = content.dup
  c = c.gsub("kettle-rb", org.to_s) if org && !org.empty?
  if gem_name && !gem_name.empty?
    # Replace occurrences of the template gem name in text, including inside
    # markdown reference labels like [🖼️kettle-dev] and identifiers like kettle-dev-i
    c = c.gsub("kettle-dev", gem_name)
    c = c.gsub(/\bKettle::Dev\b/u, namespace) unless namespace.empty?
    c = c.gsub("Kettle%3A%3ADev", namespace_shield) unless namespace_shield.empty?
    c = c.gsub("kettle--dev", gem_shield)
  end
  c
end

.ask(prompt, default) ⇒ Boolean

Simple yes/no prompt.

Parameters:

  • prompt (String)
  • default (Boolean)

Returns:

  • (Boolean)


36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# File 'lib/kettle/dev/template_helpers.rb', line 36

def ask(prompt, default)
  # Force mode: any prompt resolves to Yes when ENV["force"] is set truthy
  if ENV.fetch("force", "").to_s =~ /\A(1|true|y|yes)\z/i
    puts "#{prompt} #{default ? "[Y/n]" : "[y/N]"}: Y (forced)"
    return true
  end
  print("#{prompt} #{default ? "[Y/n]" : "[y/N]"}: ")
  ans = Kettle::Dev::InputAdapter.gets&.strip
  ans = "" if ans.nil?
  if default
    ans.empty? || ans =~ /\Ay(es)?\z/i
  else
    ans =~ /\Ay(es)?\z/i
  end
end

.copy_dir_with_prompt(src_dir, dest_dir) ⇒ void

This method returns an undefined value.

Copy a directory tree, prompting before creating or overwriting.



167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
# File 'lib/kettle/dev/template_helpers.rb', line 167

def copy_dir_with_prompt(src_dir, dest_dir)
  return unless Dir.exist?(src_dir)
  dest_exists = Dir.exist?(dest_dir)
  if dest_exists
    if ask("Replace directory #{dest_dir} (will overwrite files)?", true)
      Find.find(src_dir) do |path|
        rel = path.sub(/^#{Regexp.escape(src_dir)}\/?/, "")
        next if rel.empty?
        target = File.join(dest_dir, rel)
        if File.directory?(path)
          FileUtils.mkdir_p(target)
        else
          FileUtils.mkdir_p(File.dirname(target))
          if File.exist?(target)
            # Skip only if contents are identical. If source and target paths are the same,
            # avoid FileUtils.cp (which raises) and do an in-place rewrite to satisfy "copy".
            begin
              if FileUtils.compare_file(path, target)
                next
              elsif path == target
                data = File.binread(path)
                File.open(target, "wb") { |f| f.write(data) }
                next
              end
            rescue StandardError
              # ignore compare errors; fall through to copy
            end
          end
          FileUtils.cp(path, target)
        end
      end
      puts "Updated #{dest_dir}"
      record_template_result(dest_dir, :dir_replace)
    else
      puts "Skipped #{dest_dir}"
      record_template_result(dest_dir, :skip)
    end
  elsif ask("Create directory #{dest_dir}?", true)
    FileUtils.mkdir_p(dest_dir)
    Find.find(src_dir) do |path|
      rel = path.sub(/^#{Regexp.escape(src_dir)}\/?/, "")
      next if rel.empty?
      target = File.join(dest_dir, rel)
      if File.directory?(path)
        FileUtils.mkdir_p(target)
      else
        FileUtils.mkdir_p(File.dirname(target))
        if File.exist?(target)
          # Skip only if contents are identical. If source and target paths are the same,
          # avoid FileUtils.cp (which raises) and do an in-place rewrite to satisfy "copy".
          begin
            if FileUtils.compare_file(path, target)
              next
            elsif path == target
              data = File.binread(path)
              File.open(target, "wb") { |f| f.write(data) }
              next
            end
          rescue StandardError
            # ignore compare errors; fall through to copy
          end
        end
        FileUtils.cp(path, target)
      end
    end
    puts "Created #{dest_dir}"
    record_template_result(dest_dir, :dir_create)
  end
end

.copy_file_with_prompt(src_path, dest_path, allow_create: true, allow_replace: true) ⇒ void

This method returns an undefined value.

Copy a single file with interactive prompts for create/replace.
Yields content for transformation when block given.



136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/kettle/dev/template_helpers.rb', line 136

def copy_file_with_prompt(src_path, dest_path, allow_create: true, allow_replace: true)
  return unless File.exist?(src_path)
  dest_exists = File.exist?(dest_path)
  action = nil
  if dest_exists
    if allow_replace
      action = ask("Replace #{dest_path}?", true) ? :replace : :skip
    else
      puts "Skipping #{dest_path} (replace not allowed)."
      action = :skip
    end
  elsif allow_create
    action = ask("Create #{dest_path}?", true) ? :create : :skip
  else
    puts "Skipping #{dest_path} (create not allowed)."
    action = :skip
  end
  if action == :skip
    record_template_result(dest_path, :skip)
    return
  end

  content = File.read(src_path)
  content = yield(content) if block_given?
  write_file(dest_path, content)
  record_template_result(dest_path, dest_exists ? :replace : :create)
  puts "Wrote #{dest_path}"
end

.ensure_clean_git!(root:, task_label:) ⇒ void

This method returns an undefined value.

Ensure git working tree is clean before making changes in a task.
If not a git repo, this is a no-op.

Parameters:

  • root (String)

    project root to run git commands in

  • task_label (String)

    name of the rake task for user-facing messages (e.g., “kettle:dev:install”)

Raises:



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/kettle/dev/template_helpers.rb', line 106

def ensure_clean_git!(root:, task_label:)
  inside_repo = begin
    system("git", "-C", root.to_s, "rev-parse", "--is-inside-work-tree", out: File::NULL, err: File::NULL)
  rescue StandardError
    false
  end
  return unless inside_repo

  status_output = begin
    IO.popen(["git", "-C", root.to_s, "status", "--porcelain"], &:read).to_s
  rescue StandardError
    ""
  end
  return if status_output.strip.empty?

  puts "ERROR: Your git working tree has uncommitted changes."
  puts "#{task_label} may modify files (e.g., .github/, .gitignore, *.gemspec)."
  puts "Please commit or stash your changes, then re-run: rake #{task_label}"
  preview = status_output.lines.take(10).map(&:rstrip)
  unless preview.empty?
    puts "Detected changes:"
    preview.each { |l| puts "  #{l}" }
    puts "(showing up to first 10 lines)"
  end
  raise Kettle::Dev::Error, "Aborting: git working tree is not clean."
end

.gem_checkout_rootString

Root of this gem’s checkout (repository root when working from source)
Calculated relative to lib/kettle/dev/

Returns:

  • (String)


28
29
30
# File 'lib/kettle/dev/template_helpers.rb', line 28

def gem_checkout_root
  File.expand_path("../../..", __dir__)
end

.gemspec_metadata(root = project_root) ⇒ Hash

Parse gemspec metadata and derive useful strings

Parameters:

  • root (String) (defaults to: project_root)

    project root

Returns:

  • (Hash)


262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
# File 'lib/kettle/dev/template_helpers.rb', line 262

def (root = project_root)
  gemspecs = Dir.glob(File.join(root, "*.gemspec"))
  gemspec_path = gemspecs.first
  gemspec_text = (gemspec_path && File.file?(gemspec_path)) ? File.read(gemspec_path) : ""
  gem_name = (gemspec_text[/\bspec\.name\s*=\s*["']([^"']+)["']/, 1] || "").strip
  min_ruby = (
    gemspec_text[/\bspec\.minimum_ruby_version\s*=\s*["'](?:>=\s*)?([0-9]+\.[0-9]+(?:\.[0-9]+)?)["']/i, 1] ||
    gemspec_text[/\bspec\.required_ruby_version\s*=\s*["']>=\s*([0-9]+\.[0-9]+(?:\.[0-9]+)?)["']/i, 1] ||
    gemspec_text[/\brequired_ruby_version\s*[:=]\s*["'](?:>=\s*)?([0-9]+\.[0-9]+(?:\.[0-9]+)?)["']/i, 1] ||
    ""
  ).strip
  homepage_line = gemspec_text.lines.find { |l| l =~ /\bspec\.homepage\s*=\s*/ }
  homepage_val = homepage_line ? homepage_line.split("=", 2).last.to_s.strip : ""
  if (homepage_val.start_with?("\"") && homepage_val.end_with?("\"")) || (homepage_val.start_with?("'") && homepage_val.end_with?("'"))
    homepage_val = begin
      homepage_val[1..-2]
    rescue
      homepage_val
    end
  end
  gh_match = homepage_val&.match(%r{github\.com/([^/]+)/([^/]+)}i)
  forge_org = gh_match && gh_match[1]
  gh_repo = gh_match && gh_match[2]&.sub(/\.git\z/, "")
  if forge_org.nil?
    begin
      origin_out = IO.popen(["git", "-C", root.to_s, "remote", "get-url", "origin"], &:read)
      origin_out = origin_out.read if origin_out.respond_to?(:read)
      origin_url = origin_out.to_s.strip
      if (m = origin_url.match(%r{github\.com[/:]([^/]+)/([^/]+)}i))
        forge_org = m[1]
        gh_repo = m[2]&.sub(/\.git\z/, "")
      end
    rescue StandardError
      # ignore
    end
  end

  camel = lambda do |s|
    s.split(/[_-]/).map { |p| p.gsub(/\b([a-z])/) { Regexp.last_match(1).upcase } }.join
  end
  namespace = gem_name.to_s.split("-").map { |seg| camel.call(seg) }.join("::")
  namespace_shield = namespace.gsub("::", "%3A%3A")
  entrypoint_require = gem_name.to_s.tr("-", "/")
  gem_shield = gem_name.to_s.gsub("-", "--").gsub("_", "__")

  # Determine funding_org independently of forge_org (GitHub org)
  funding_org = ENV["FUNDING_ORG"].to_s.strip
  funding_org = ENV["OPENCOLLECTIVE_ORG"].to_s.strip if funding_org.empty?
  funding_org = ENV["OPENCOLLECTIVE_HANDLE"].to_s.strip if funding_org.empty?
  if funding_org.empty?
    begin
      oc_path = File.join(root.to_s, ".opencollective.yml")
      if File.file?(oc_path)
        txt = File.read(oc_path)
        if (m = txt.match(/\borg:\s*([\w\-]+)/i))
          funding_org = m[1].to_s
        end
      end
    rescue StandardError
      # ignore
    end
  end
  funding_org = forge_org.to_s if funding_org.to_s.empty?

  {
    gemspec_path: gemspec_path,
    gem_name: gem_name,
    min_ruby: min_ruby,
    homepage: homepage_val,
    gh_org: forge_org, # Backward compat: keep old key synonymous with forge_org
    forge_org: forge_org,
    funding_org: funding_org,
    gh_repo: gh_repo,
    namespace: namespace,
    namespace_shield: namespace_shield,
    entrypoint_require: entrypoint_require,
    gem_shield: gem_shield,
  }
end

.modified_by_template?(dest_path) ⇒ Boolean

Returns true if the given path was created or replaced by the template task in this run

Parameters:

  • dest_path (String)

Returns:

  • (Boolean)


95
96
97
98
99
# File 'lib/kettle/dev/template_helpers.rb', line 95

def modified_by_template?(dest_path)
  rec = @@template_results[File.expand_path(dest_path.to_s)]
  return false unless rec
  [:create, :replace, :dir_create, :dir_replace].include?(rec[:action])
end

.prefer_example(src_path) ⇒ String

Prefer an .example variant for a given source path when present
For a given intended source path (e.g., “/src/Rakefile”), this will return
“/src/Rakefile.example” if it exists, otherwise returns the original path.
If the given path already ends with .example, it is returned as-is.

Parameters:

  • src_path (String)

Returns:

  • (String)


67
68
69
70
71
# File 'lib/kettle/dev/template_helpers.rb', line 67

def prefer_example(src_path)
  return src_path if src_path.end_with?(".example")
  example = src_path + ".example"
  File.exist?(example) ? example : src_path
end

.project_rootString

Root of the host project where Rake was invoked

Returns:

  • (String)


21
22
23
# File 'lib/kettle/dev/template_helpers.rb', line 21

def project_root
  CIHelpers.project_root
end

.record_template_result(dest_path, action) ⇒ void

This method returns an undefined value.

Record a template action for a destination path

Parameters:

  • dest_path (String)
  • action (Symbol)

    one of :create, :replace, :skip, :dir_create, :dir_replace



77
78
79
80
81
82
83
84
# File 'lib/kettle/dev/template_helpers.rb', line 77

def record_template_result(dest_path, action)
  abs = File.expand_path(dest_path.to_s)
  if action == :skip && @@template_results.key?(abs)
    # Preserve the last meaningful action; do not downgrade to :skip
    return
  end
  @@template_results[abs] = {action: action, timestamp: Time.now}
end

.template_resultsHash

Access all template results (read-only clone)

Returns:

  • (Hash)


88
89
90
# File 'lib/kettle/dev/template_helpers.rb', line 88

def template_results
  @@template_results.clone
end

.write_file(dest_path, content) ⇒ void

This method returns an undefined value.

Write file content creating directories as needed

Parameters:

  • dest_path (String)
  • content (String)


56
57
58
59
# File 'lib/kettle/dev/template_helpers.rb', line 56

def write_file(dest_path, content)
  FileUtils.mkdir_p(File.dirname(dest_path))
  File.open(dest_path, "w") { |f| f.write(content) }
end