Module: Tng::Utils

Defined in:
lib/tng/utils.rb

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.center_text_static(text, width = 80) ⇒ Object



540
541
542
543
544
545
546
547
548
# File 'lib/tng/utils.rb', line 540

def self.center_text_static(text, width = 80)
  lines = text.split("\n")
  lines.map do |line|
    # Remove ANSI color codes for length calculation
    clean_line = line.gsub(/\e\[[0-9;]*m/, "")
    padding = [(width - clean_line.length) / 2, 0].max
    " " * padding + line
  end.join("\n")
end

.cleanup_file_content(content) ⇒ Object



223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
# File 'lib/tng/utils.rb', line 223

def self.cleanup_file_content(content)
  return content unless content.is_a?(String)

  cleaned = content.dup

  # Strip trailing whitespace first
  cleaned.rstrip!

  # Remove trailing JSON artifacts that shouldn't be in Ruby code
  # Common patterns: ends with `end"` or `end"\n}` or just `}`
  loop do
    original = cleaned.dup

    # Remove trailing lone } or " that shouldn't be there
    cleaned.sub!(/\n\s*\}\s*\z/, "\n")
    cleaned.sub!(/"\s*\z/, "")
    cleaned.sub!(/\n\s*"\s*\z/, "\n")
    cleaned.sub!(/end"\s*\z/, "end")

    break if cleaned == original
  end

  # Ensure file ends with single newline
  cleaned.rstrip!
  cleaned << "\n" unless cleaned.empty?

  cleaned
end

.count_test_nodes(node) ⇒ Object



392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
# File 'lib/tng/utils.rb', line 392

def self.count_test_nodes(node)
  return 0 unless node.respond_to?(:child_nodes)

  count = 0

  # Check if current node is a test
  count += 1 if test_node?(node)

  # Recursively check child nodes
  node.child_nodes.each do |child|
    count += count_test_nodes(child) if child
  end

  count
end

.count_tests_in_file(file_path) ⇒ Object



377
378
379
380
381
382
383
384
385
386
387
388
389
390
# File 'lib/tng/utils.rb', line 377

def self.count_tests_in_file(file_path)
  return 0 unless File.exist?(file_path)

  begin
    content = File.read(file_path)
    result = Prism.parse(content)
    return 0 unless result.success?

    count_test_nodes(result.value)
  rescue StandardError => e
    puts "⚠️  Warning: Could not parse #{file_path}: #{e.message}" if ENV["DEBUG"]
    0
  end
end

.display_test_counts(passed, failed, skipped, total, success, pastel, terminal_width) ⇒ Object



512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
# File 'lib/tng/utils.rb', line 512

def self.display_test_counts(passed, failed, skipped, total, success, pastel, terminal_width)
  passed_icon = pastel.decorate(Tng::UI::Theme.icon(:success), Tng::UI::Theme.color(:primary))
  failed_icon = pastel.decorate(Tng::UI::Theme.icon(:error), Tng::UI::Theme.color(:primary))
  skipped_icon = pastel.decorate("⏭️", Tng::UI::Theme.color(:accent))
  total_icon = pastel.decorate(Tng::UI::Theme.icon(:marker), Tng::UI::Theme.color(:primary))

  passed_text = pastel.decorate("#{passed_icon} #{passed} passed", Tng::UI::Theme.color(:success))
  failed_text = pastel.decorate("#{failed_icon} #{failed} failed", Tng::UI::Theme.color(:error))
  skipped_text = if skipped.positive?
                   pastel.decorate("#{skipped_icon} #{skipped} skipped",
                                   Tng::UI::Theme.color(:warning))
                 else
                   nil
                 end
  total_text = pastel.decorate("#{total_icon} #{total} total", Tng::UI::Theme.color(:secondary))

  results = [passed_text, failed_text, skipped_text, total_text].compact.join(", ")
  puts center_text_static(results, terminal_width)

  # Overall result
  overall_msg = if success
                  pastel.decorate("#{Tng::UI::Theme.icon(:success)} All tests passed!", Tng::UI::Theme.color(:success))
                else
                  pastel.decorate("#{Tng::UI::Theme.icon(:error)} Some tests failed", Tng::UI::Theme.color(:error))
                end
  puts center_text_static(overall_msg, terminal_width)
end

.filter_ignored_files(files, root: nil, path_key: :path) ⇒ Object



163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/tng/utils.rb', line 163

def self.filter_ignored_files(files, root: nil, path_key: :path)
  return [] if files.nil?
  root ||= project_root

  files.reject do |file|
    candidate =
      if file.is_a?(Hash)
        file[path_key] || file[path_key.to_s]
      else
        file
      end
    ignored_path?(candidate, root: root)
  end
end

.fixture_contentObject



252
253
254
255
256
257
258
259
260
261
262
263
264
# File 'lib/tng/utils.rb', line 252

def self.fixture_content
  # Auto-detect: try fixtures first, then factory_bot, then fabricator
  fixtures = load_all_fixtures_data
  return fixtures if fixtures.any?

  factories = load_all_factory_data
  return factories if factories.any?

  fabricators = load_all_fabricator_data
  return fabricators if fabricators.any?

  {}
end

.format_generation_time(seconds) ⇒ Object



550
551
552
553
554
555
556
557
558
559
560
# File 'lib/tng/utils.rb', line 550

def self.format_generation_time(seconds)
  if seconds < 1
    "#{(seconds * 1000).round}ms"
  elsif seconds < 60
    "#{seconds.round(1)}s"
  else
    minutes = (seconds / 60).floor
    remaining_seconds = (seconds % 60).round
    "#{minutes}m #{remaining_seconds}s"
  end
end

.has_gem?(gem_name) ⇒ Boolean

Returns:

  • (Boolean)


106
107
108
109
110
111
112
113
# File 'lib/tng/utils.rb', line 106

def self.has_gem?(gem_name)
  return false unless defined?(Bundler)

  gemfile_specs = Bundler.load.specs
  gemfile_specs.any? { |spec| spec.name == gem_name }
rescue StandardError
  false
end

.ignored_path?(path, root: nil) ⇒ Boolean

Returns:

  • (Boolean)


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
# File 'lib/tng/utils.rb', line 137

def self.ignored_path?(path, root: nil)
  return false unless path

  root ||= project_root
  target = normalize_path(path.to_s, root)
  return false unless target

  ignore_files = Array(Tng.config[:ignore_files])
  ignore_folders = Array(Tng.config[:ignore_folders])

  ignore_files.each do |entry|
    resolved = normalize_path(entry.to_s, root)
    next unless resolved
    return true if resolved == target
  end

  ignore_folders.each do |entry|
    resolved = normalize_path(entry.to_s, root)
    next unless resolved
    resolved = resolved.end_with?("/") ? resolved.chomp("/") : resolved
    return true if target == resolved || target.start_with?(resolved + "/")
  end

  false
end

.load_all_active_record_dataObject



373
374
375
# File 'lib/tng/utils.rb', line 373

def self.load_all_active_record_data
  {}
end

.load_all_fabricator_dataObject



290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
# File 'lib/tng/utils.rb', line 290

def self.load_all_fabricator_data
  fabricator_data = {}
  return fabricator_data unless defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
  fabricators_dir = ::Rails.root.join("spec", "fabricators")

  return fabricator_data unless Dir.exist?(fabricators_dir)

  Dir.glob("#{fabricators_dir}/*_fabricator.rb").each do |fabricator_file|
    model_name = File.basename(fabricator_file, "_fabricator.rb")

    begin
      content = File.read(fabricator_file)
      fabricator_data[model_name] = parse_fabricator_structure(content, model_name)
    rescue StandardError => e
      puts "⚠️  Warning: Could not load fabricator file #{fabricator_file}: #{e.message}"
    end
  end

  fabricator_data
end

.load_all_factory_dataObject



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
# File 'lib/tng/utils.rb', line 311

def self.load_all_factory_data
  factory_data = {}
  return factory_data unless defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
  factory_dirs = [
    ::Rails.root.join("spec", "factories"),
    ::Rails.root.join("test", "factories")
  ]

  factory_dirs.each do |factory_dir|
    next unless Dir.exist?(factory_dir)

    Dir.glob("#{factory_dir}/*.rb").each do |factory_file|
      content = File.read(factory_file)

      begin
        # Extract all factory definitions from the file
        content.scan(/factory\s+:(\w+)\s+do/) do |match|
          model_name = match[0]
          factory_data[model_name] = parse_factory_structure(content, model_name)
        end
      rescue StandardError => e
        puts "⚠️  Warning: Could not load factory file #{factory_file}: #{e.message}"
      end
    end
  end

  factory_data
end

.load_all_fixtures_dataObject



266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
# File 'lib/tng/utils.rb', line 266

def self.load_all_fixtures_data
  fixture_data = {}
  # TODO: Load proper folder for Rspec. This is only valid for minitest.
  return fixture_data unless defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
  fixtures_dir = ::Rails.root.join("test", "fixtures")

  return fixture_data unless Dir.exist?(fixtures_dir)

  fixture_files = Dir.glob("#{fixtures_dir}/*.yml")

  fixture_files.each do |fixture_file|
    model_name = File.basename(fixture_file, ".yml")

    begin
      fixtures = YAML.load_file(fixture_file)
      fixture_data[model_name] = fixtures
    rescue StandardError => e
      puts "⚠️  Warning: Could not load fixture file #{fixture_file}: #{e.message}"
    end
  end

  fixture_data
end

.normalize_path(entry, root) ⇒ Object



123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/tng/utils.rb', line 123

def self.normalize_path(entry, root)
  return nil unless entry.is_a?(String)

  trimmed = entry.strip
  return nil if trimmed.empty?

  normalized = trimmed.tr("\\", "/")
  if normalized.start_with?("/")
    File.expand_path(normalized)
  else
    File.expand_path(normalized, root)
  end
end

.parse_attribute_value(value) ⇒ Object



368
369
370
371
# File 'lib/tng/utils.rb', line 368

def self.parse_attribute_value(value)
  # Clean up and parse attribute values
  value.strip.gsub(/^["']|["']$/, "")
end

.parse_fabricator_structure(content, model_name) ⇒ Object



340
341
342
343
344
345
346
347
348
349
350
351
352
# File 'lib/tng/utils.rb', line 340

def self.parse_fabricator_structure(content, model_name)
  # Parse fabricator file to extract attribute structure
  attributes = {}

  # Look for Fabricator definitions
  content.scan(/Fabricator\(:#{model_name}\) do \|f\|(.*?)end/m) do |block|
    block[0].scan(/f\.(\w+)\s+(.+)/) do |attr, value|
      attributes[attr] = parse_attribute_value(value)
    end
  end

  { "attributes" => attributes, "type" => "fabricator" }
end

.parse_factory_structure(content, model_name) ⇒ Object



354
355
356
357
358
359
360
361
362
363
364
365
366
# File 'lib/tng/utils.rb', line 354

def self.parse_factory_structure(content, model_name)
  # Parse factory file to extract attribute structure
  attributes = {}

  # Look for factory definitions
  content.scan(/factory :#{model_name} do(.*?)end/m) do |block|
    block[0].scan(/(\w+)\s+(.+)/) do |attr, value|
      attributes[attr] = parse_attribute_value(value)
    end
  end

  { "attributes" => attributes, "type" => "factory" }
end

.parse_minitest_results(output, exit_code, pastel, terminal_width) ⇒ Object



475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
# File 'lib/tng/utils.rb', line 475

def self.parse_minitest_results(output, exit_code, pastel, terminal_width)
  # Minitest output example: "7 tests, 15 assertions, 2 failures, 1 errors, 0 skips"
  # or "Run options: --seed 12345"
  # "7 tests, 2 assertions, 0 failures, 0 errors, 0 skips"

  lines = output.lines
  # Look for summary line - could be "X tests" or "X runs"
  summary_line = lines.find { |line| line.match?(/\d+ (?:tests?|runs?),/) }

  if summary_line
    # Extract numbers from summary - handle both "tests" and "runs" format
    match = summary_line.match(/(\d+) (?:tests?|runs?), (\d+) assertions?, (\d+) failures?, (\d+) errors?, (\d+) skips?/)
    if match
      total = match[1].to_i
      failures = match[3].to_i
      errors = match[4].to_i
      skips = match[5].to_i
      passed = total - failures - errors - skips

      display_test_counts(passed, failures + errors, skips, total, exit_code.zero?, pastel, terminal_width)
    else
      puts center_text_static(pastel.decorate("Could not parse Minitest results", Tng::UI::Theme.color(:error)),
                              terminal_width)
      puts center_text_static("Expected format: 'X tests, Y assertions, Z failures, A errors, B skips'",
                              terminal_width)
      puts center_text_static("Got: #{summary_line.strip}", terminal_width)
    end
  else
    puts center_text_static(pastel.decorate("No test results found", Tng::UI::Theme.color(:warning)),
                            terminal_width)
    puts center_text_static("Looking for line with test counts...", terminal_width)
    # Show last few lines for debugging
    last_lines = output.lines.last(3).map(&:strip).join(" | ")
    puts center_text_static("Last lines: #{last_lines}", terminal_width) if output.lines.any?
  end
end

.parse_rspec_json_results(output, exit_code, pastel, terminal_width) ⇒ Object

Test result parsing methods



424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
# File 'lib/tng/utils.rb', line 424

def self.parse_rspec_json_results(output, exit_code, pastel, terminal_width)
  # Parse RSpec JSON output
  json_data = JSON.parse(output)
  summary = json_data["summary"] || json_data

  total = summary["example_count"] || summary["total_examples"] || 0
  failures = summary["failure_count"] || summary["failures"] || 0
  pending = summary["pending_count"] || summary["pending"] || 0
  errors = summary["errors_outside_of_examples_count"] || 0

  passed = total - failures - pending - errors

  display_test_counts(passed, failures + errors, pending, total, exit_code.zero?, pastel, terminal_width)
rescue JSON::ParserError
  # Fallback to text parsing if JSON fails
  puts center_text_static(
    pastel.decorate("JSON parsing failed, falling back to text parsing",
                    Tng::UI::Theme.color(:warning)), terminal_width
  )
  parse_rspec_results(output, exit_code, pastel, terminal_width)
end

.parse_rspec_results(output, exit_code, pastel, terminal_width) ⇒ Object



446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
# File 'lib/tng/utils.rb', line 446

def self.parse_rspec_results(output, exit_code, pastel, terminal_width)
  # RSpec output example: "7 examples, 2 failures"
  # or "Finished in 0.12345 seconds (files took 0.01234 seconds to load)"
  # "7 examples, 2 failures, 1 pending"

  lines = output.lines
  summary_line = lines.find { |line| line.match?(/\d+ examples?,/) }

  if summary_line
    # Extract numbers from summary
    match = summary_line.match(/(\d+) examples?, (\d+) failures?(?:, (\d+) pending)?/)
    if match
      total = match[1].to_i
      failures = match[2].to_i
      pending = match[3].to_i || 0
      passed = total - failures - pending

      display_test_counts(passed, failures, pending, total, exit_code == 0, pastel, terminal_width)
    else
      puts center_text_static(pastel.decorate("Could not parse RSpec results", Tng::UI::Theme.color(:error)),
                              terminal_width)
      puts center_text_static(output.lines.last.strip, terminal_width) if output.lines.any?
    end
  else
    puts center_text_static(pastel.decorate("No test results found", Tng::UI::Theme.color(:warning)),
                            terminal_width)
  end
end

.project_rootObject



115
116
117
118
119
120
121
# File 'lib/tng/utils.rb', line 115

def self.project_root
  if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
    ::Rails.root.to_s
  else
    Dir.pwd
  end
end

.rails_project?(root = Dir.pwd) ⇒ Boolean

Returns:

  • (Boolean)


8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# File 'lib/tng/utils.rb', line 8

def self.rails_project?(root = Dir.pwd)
  checks = 0

  app_rb = File.join(root, "config", "application.rb")
  checks += 1 if File.exist?(app_rb)

  env_rb = File.join(root, "config", "environment.rb")
  checks += 1 if File.exist?(env_rb)

  gemfile = File.join(root, "Gemfile")
  if File.exist?(gemfile)
    begin
      content = File.read(gemfile)
      checks += 1 if content.match?(/gem ['"]rails['"]/)
    rescue StandardError
      # ignore read errors
    end
  end

  config_ru = File.join(root, "config.ru")
  if File.exist?(config_ru)
    begin
      content = File.read(config_ru)
      checks += 1 if content.match?(/run\s+Rails\.application|Rails::Application/)
    rescue StandardError
      # ignore read errors
    end
  end

  checks >= 3
end

.run_tests(file_path) ⇒ Object



575
576
577
578
579
580
581
582
583
# File 'lib/tng/utils.rb', line 575

def self.run_tests(file_path)
  command = if file_path.include?("/spec/")
              "bundle exec rspec #{file_path}"
            else
              "bundle exec rails test #{file_path}"
            end
  output = `#{command} 2>&1`
  { success: $?.success?, output: output, command: command }
end

.save_test_file(test_content) ⇒ Object



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
# File 'lib/tng/utils.rb', line 178

def self.save_test_file(test_content)
  puts "📋 Raw API response: #{test_content[0..200]}..." if ENV["DEBUG"]
  parsed_response = JSON.parse(test_content)

  return { error: :generation_failed, message: parsed_response["error"] } if parsed_response["error"]
  # Validate required fields
  unless parsed_response["file_content"]
    return { error: :invalid_response, message: "API response missing file_content" }
  end

  # Handle both possible field names for file path
  file_path = parsed_response["test_file_path"] || parsed_response["file_path"] || parsed_response["file_name"] || parsed_response["file"]
  return { error: :invalid_response, message: "API response missing file path" } unless file_path

  begin
    clean_content = cleanup_file_content(parsed_response["file_content"])
    File.write(file_path, clean_content)
  rescue Errno::ENOENT
    # Create directory if it doesn't exist
    FileUtils.mkdir_p(File.dirname(file_path))
    clean_content = cleanup_file_content(parsed_response["file_content"])
    File.write(file_path, clean_content)
  end
  absolute_path = File.expand_path(file_path)

  # Count tests in the generated file
  test_count = count_tests_in_file(file_path)

  # Determine run command based on test framework
  run_command = if file_path.include?("/spec/")
                  "bundle exec rspec #{file_path}"
                else
                  "bundle exec rails test #{file_path}"
                end

  # Return file information for CLI to use
  {
    file_path: file_path,
    absolute_path: absolute_path,
    run_command: run_command,
    test_class_name: parsed_response["test_class_name"],
    test_count: test_count
  }
end

.test_node?(node) ⇒ Boolean

Returns:

  • (Boolean)


408
409
410
411
412
413
414
415
416
417
418
419
420
421
# File 'lib/tng/utils.rb', line 408

def self.test_node?(node)
  case node
  when Prism::DefNode
    # Minitest: def test_something
    node.name.to_s.start_with?("test_")
  when Prism::CallNode
    # RSpec: it "...", specify "..."
    # Minitest: test "..."
    method_name = node.name.to_s
    %w[it specify test].include?(method_name)
  else
    false
  end
end

.validate_rubocop(file_path) ⇒ Object



567
568
569
570
571
572
573
# File 'lib/tng/utils.rb', line 567

def self.validate_rubocop(file_path)
  # Run rubocop with JSON output
  output = `bundle exec rubocop #{file_path} --format json 2>&1`
  { success: $?.success?, output: output }
rescue StandardError => e
  { success: false, output: "Rubocop error: #{e.message}" }
end

.validate_ruby_syntax(file_path) ⇒ Object



562
563
564
565
# File 'lib/tng/utils.rb', line 562

def self.validate_ruby_syntax(file_path)
  output = `ruby -c #{file_path} 2>&1`
  { success: $?.success?, output: output }
end

Instance Method Details

#camelize(str, first_letter = :upper) ⇒ Object



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/tng/utils.rb', line 79

def camelize(str, first_letter = :upper)
  # Use Rails' camelize method if available, otherwise use custom implementation
  if defined?(ActiveSupport::Inflector) && ActiveSupport::Inflector.respond_to?(:camelize)
    case first_letter
    when :upper
      ActiveSupport::Inflector.camelize(str, true)
    when :lower
      ActiveSupport::Inflector.camelize(str, false)
    else
      raise ArgumentError, "Invalid option, use either :upper or :lower."
    end
  elsif str.respond_to?(:camelize)
    str.camelize(first_letter)
  else
    # Custom implementation
    result = str.gsub(/(?:^|_)([a-z])/) { Regexp.last_match(1).upcase }
    case first_letter
    when :upper
      result
    when :lower
      result[0].downcase + result[1..] if result.length.positive?
    else
      raise ArgumentError, "Invalid option, use either :upper or :lower."
    end
  end
end

#center_text(text, width = nil) ⇒ Object



48
49
50
51
52
53
54
55
56
57
# File 'lib/tng/utils.rb', line 48

def center_text(text, width = nil)
  width ||= @terminal_width || 80 # Use fallback if still nil
  lines = text.split("\n")
  lines.map do |line|
    # Remove ANSI color codes for length calculation
    clean_line = line.gsub(/\e\[[0-9;]*m/, "")
    padding = [(width - clean_line.length) / 2, 0].max
    " " * padding + line
  end.join("\n")
end

#clear_screenObject



44
45
46
# File 'lib/tng/utils.rb', line 44

def clear_screen
  system("clear") || system("cls")
end

#find_rails_rootObject



69
70
71
72
73
74
75
76
77
# File 'lib/tng/utils.rb', line 69

def find_rails_root
  current_dir = Dir.pwd
  while current_dir != "/"
    return current_dir if File.exist?(File.join(current_dir, "config", "application.rb"))

    current_dir = File.dirname(current_dir)
  end
  nil
end

#load_rails_environmentObject



59
60
61
62
63
64
65
66
67
# File 'lib/tng/utils.rb', line 59

def load_rails_environment
  # Use bundler environment to avoid gem conflicts
  require "bundler/setup"
  require "./config/environment"
  true
rescue LoadError => e
  puts "Failed to load Rails: #{e.message}"
  false
end

#rails_project?(root = Dir.pwd) ⇒ Boolean

Returns:

  • (Boolean)


40
41
42
# File 'lib/tng/utils.rb', line 40

def rails_project?(root = Dir.pwd)
  Utils.rails_project?(root)
end