ruby-maat 1.0.0 → 1.3.4

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.
data/lib/ruby_maat/cli.rb CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  require "optparse"
4
4
  require "date"
5
+ require_relative "generators/git_generator"
6
+ require_relative "generators/svn_generator"
7
+ require_relative "analysis_presets"
8
+ require_relative "vcs_detector"
5
9
 
6
10
  module RubyMaat
7
11
  # Command Line Interface - Ruby port of code-maat.cmd-line
@@ -23,8 +27,12 @@ module RubyMaat
23
27
 
24
28
  validate_required_options!
25
29
 
26
- app = App.new(@options)
27
- app.run
30
+ if @options[:generate_log] || @options[:interactive]
31
+ handle_log_generation
32
+ else
33
+ app = App.new(@options)
34
+ app.run
35
+ end
28
36
  rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
29
37
  warn "Error: #{e.message}"
30
38
  warn usage
@@ -41,6 +49,212 @@ module RubyMaat
41
49
 
42
50
  private
43
51
 
52
+ def handle_log_generation
53
+ if @options[:interactive]
54
+ handle_interactive_mode
55
+ else
56
+ generator = create_log_generator
57
+ output_file = @options[:save_log]
58
+ preset_options = get_preset_options if @options[:preset]
59
+
60
+ log_output = generator.generate_log(output_file, **(preset_options || {}))
61
+
62
+ if output_file
63
+ puts "Log generated: #{output_file}"
64
+ # If we saved to file and analysis is specified, run analysis on that file
65
+ if @options[:analysis] && @options[:analysis] != "authors"
66
+ puts "\n=== Running Analysis ==="
67
+ analysis_options = @options.merge(log: output_file)
68
+ app = App.new(analysis_options)
69
+ app.run
70
+ end
71
+ elsif log_output
72
+ # If no output file specified and we have an analysis, run it on the generated log
73
+ puts "\n=== Running Analysis ==="
74
+
75
+ require "tempfile"
76
+ temp_log = Tempfile.new(["ruby_maat", ".log"])
77
+ temp_log.write(log_output)
78
+ temp_log.close
79
+
80
+ analysis_options = @options.merge(log: temp_log.path)
81
+ app = App.new(analysis_options)
82
+ app.run
83
+
84
+ temp_log.unlink
85
+ end
86
+ end
87
+ end
88
+
89
+ def handle_interactive_mode
90
+ unless $stdin.tty?
91
+ raise "Interactive mode requires a terminal (TTY). Use --generate-log with presets instead."
92
+ end
93
+
94
+ puts "=== Ruby Maat Interactive Mode ==="
95
+ puts
96
+
97
+ # Step 1: Detect or choose VCS
98
+ vcs_type = @options[:version_control] || detect_vcs_interactive
99
+
100
+ # Step 2: Choose analysis type
101
+ analysis_type = @options[:analysis] || choose_analysis_interactive
102
+
103
+ # Step 3: Generate log and run analysis
104
+ generator = create_log_generator_for_vcs(vcs_type)
105
+ log_output = generator.interactive_generate_for_analysis(analysis_type, @options)
106
+
107
+ # Step 4: Run analysis if log was generated to stdout
108
+ if log_output && !@options[:save_log]
109
+ puts "\n=== Running Analysis ==="
110
+
111
+ # Create temporary log file for analysis
112
+ require "tempfile"
113
+ temp_log = Tempfile.new(["ruby_maat", ".log"])
114
+ temp_log.write(log_output)
115
+ temp_log.close
116
+
117
+ # Run analysis
118
+ analysis_options = @options.merge(
119
+ log: temp_log.path,
120
+ version_control: (vcs_type == "git") ? "git2" : vcs_type,
121
+ analysis: analysis_type
122
+ )
123
+
124
+ app = App.new(analysis_options)
125
+ app.run
126
+
127
+ temp_log.unlink
128
+ end
129
+ end
130
+
131
+ def create_log_generator
132
+ case @options[:version_control]
133
+ when "git", "git2"
134
+ RubyMaat::Generators::GitGenerator.new(".", @options)
135
+ when "svn"
136
+ RubyMaat::Generators::SvnGenerator.new(".", @options)
137
+ else
138
+ raise ArgumentError, "Log generation not yet supported for #{@options[:version_control]}"
139
+ end
140
+ end
141
+
142
+ def get_preset_options
143
+ generator = create_log_generator
144
+ presets = generator.available_presets
145
+
146
+ unless presets.key?(@options[:preset])
147
+ available = presets.keys.join(", ")
148
+ raise ArgumentError, "Unknown preset '#{@options[:preset]}'. Available: #{available}"
149
+ end
150
+
151
+ presets[@options[:preset]][:options]
152
+ end
153
+
154
+ def create_log_generator_for_vcs(vcs_type)
155
+ case vcs_type
156
+ when "git", "git2"
157
+ RubyMaat::Generators::GitGenerator.new(".", @options)
158
+ when "svn"
159
+ RubyMaat::Generators::SvnGenerator.new(".", @options)
160
+ else
161
+ raise ArgumentError, "Log generation not yet supported for #{vcs_type}"
162
+ end
163
+ end
164
+
165
+ def detect_vcs_interactive
166
+ detected = RubyMaat::VcsDetector.detect_vcs
167
+
168
+ if detected
169
+ puts "Detected VCS: #{RubyMaat::VcsDetector.vcs_description(detected)}"
170
+ if ask_yes_no_interactive("Use detected VCS?", true)
171
+ return detected
172
+ end
173
+ end
174
+
175
+ choose_vcs_interactive
176
+ end
177
+
178
+ def choose_vcs_interactive
179
+ puts "Choose version control system:"
180
+ vcs_options = %w[git svn hg p4 tfs]
181
+ vcs_options.each_with_index do |vcs, index|
182
+ puts " #{index + 1}. #{RubyMaat::VcsDetector.vcs_description(vcs)}"
183
+ end
184
+
185
+ choice = ask_integer_interactive("Choose VCS", 1, vcs_options.length)
186
+ vcs_options[choice - 1]
187
+ end
188
+
189
+ def choose_analysis_interactive
190
+ analyses = RubyMaat::AnalysisPresets.available_analyses
191
+
192
+ puts "Choose analysis type:"
193
+ analyses.each_with_index do |analysis, index|
194
+ puts " #{index + 1}. #{RubyMaat::AnalysisPresets.analysis_description(analysis)}"
195
+ end
196
+
197
+ choice = ask_integer_interactive("Choose analysis", 1, analyses.length)
198
+ analyses[choice - 1]
199
+ end
200
+
201
+ def ask_yes_no_interactive(prompt, default = nil)
202
+ default_text = case default
203
+ when true then " [Y/n]"
204
+ when false then " [y/N]"
205
+ else " [y/n]"
206
+ end
207
+
208
+ loop do
209
+ print "#{prompt}#{default_text}: "
210
+ response = $stdin.gets
211
+ return default if response.nil?
212
+ response = response.chomp.downcase
213
+
214
+ case response
215
+ when "y", "yes"
216
+ return true
217
+ when "n", "no"
218
+ return false
219
+ when ""
220
+ return default unless default.nil?
221
+ end
222
+
223
+ puts "Please enter 'y' or 'n'"
224
+ end
225
+ end
226
+
227
+ def ask_integer_interactive(prompt, min = nil, max = nil)
228
+ attempts = 0
229
+ max_attempts = 10
230
+
231
+ loop do
232
+ attempts += 1
233
+ if attempts > max_attempts
234
+ raise "Too many invalid attempts. Exiting interactive mode."
235
+ end
236
+
237
+ print "#{prompt}: "
238
+ response = $stdin.gets
239
+ return 1 if response.nil? # Default to first option
240
+
241
+ response = response.chomp
242
+
243
+ if response.empty?
244
+ puts "Please enter a valid number"
245
+ next
246
+ end
247
+
248
+ begin
249
+ value = Integer(response)
250
+ return value if (min.nil? || value >= min) && (max.nil? || value <= max)
251
+ puts "Please enter a number between #{min} and #{max}"
252
+ rescue ArgumentError
253
+ puts "Please enter a valid number"
254
+ end
255
+ end
256
+ end
257
+
44
258
  def build_option_parser
45
259
  OptionParser.new do |opts|
46
260
  opts.banner = usage_banner
@@ -113,6 +327,23 @@ module RubyMaat
113
327
  @options[:max_changeset_size] = max_size
114
328
  end
115
329
 
330
+ # Log generation options
331
+ opts.on("--generate-log", "Generate log file instead of running analysis") do
332
+ @options[:generate_log] = true
333
+ end
334
+
335
+ opts.on("--save-log FILENAME", "Save generated log to file") do |filename|
336
+ @options[:save_log] = filename
337
+ end
338
+
339
+ opts.on("--interactive", "Use interactive mode for log generation") do
340
+ @options[:interactive] = true
341
+ end
342
+
343
+ opts.on("--preset PRESET", "Use a preset configuration for log generation") do |preset|
344
+ @options[:preset] = preset
345
+ end
346
+
116
347
  # Analysis-specific options
117
348
  opts.on("-e", "--expression-to-match MATCH_EXPRESSION",
118
349
  "A regex to match against commit messages. Used with -messages analyses") do |expression|
@@ -131,6 +362,12 @@ module RubyMaat
131
362
  raise ArgumentError, "Invalid date format for --age-time-now: #{date_str}. Use YYYY-MM-dd format."
132
363
  end
133
364
 
365
+ opts.on("--group-by-merge",
366
+ "Group commits by merge commit for PR-level coupling analysis. " \
367
+ "Requires log generated with parent hashes (use --preset pr-coupling).") do
368
+ @options[:group_by_merge] = true
369
+ end
370
+
134
371
  opts.on("--verbose-results",
135
372
  "Includes additional analysis details together with the results. Only implemented for change coupling.") do
136
373
  @options[:verbose_results] = true
@@ -158,7 +395,10 @@ module RubyMaat
158
395
 
159
396
  This is Ruby Maat, a Ruby port of Code Maat - a program used to collect statistics from a VCS.
160
397
 
161
- Usage: ruby-maat -l log-file -c vcs-type [options]
398
+ Usage:
399
+ ruby-maat -l log-file -c vcs-type [options] # Run analysis on existing log
400
+ ruby-maat --generate-log -c vcs-type [options] # Generate log file
401
+ ruby-maat --generate-log --interactive -c vcs-type # Interactive log generation
162
402
 
163
403
  Options:
164
404
  BANNER
@@ -170,8 +410,22 @@ module RubyMaat
170
410
 
171
411
  def validate_required_options!
172
412
  missing = []
173
- missing << "log file (-l/--log)" unless @options[:log]
174
- missing << "version control system (-c/--version-control)" unless @options[:version_control]
413
+
414
+ # In interactive mode, we can detect everything
415
+ if @options[:interactive]
416
+ # Interactive mode can work with no other options
417
+ return
418
+ end
419
+
420
+ # Log file is only required when not generating logs
421
+ unless @options[:generate_log] || @options[:log]
422
+ missing << "log file (-l/--log)"
423
+ end
424
+
425
+ # VCS is required for non-interactive modes
426
+ unless @options[:version_control]
427
+ missing << "version control system (-c/--version-control)"
428
+ end
175
429
 
176
430
  raise ArgumentError, "Missing required options: #{missing.join(", ")}" unless missing.empty?
177
431
 
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tempfile"
5
+
6
+ module RubyMaat
7
+ module Generators
8
+ class BaseGenerator
9
+ attr_reader :repository_path, :options
10
+
11
+ def initialize(repository_path = ".", options = {})
12
+ @repository_path = File.expand_path(repository_path)
13
+ @options = options
14
+ validate_repository!
15
+ end
16
+
17
+ def generate_log(output_file = nil, **generation_options)
18
+ merged_options = @options.merge(generation_options)
19
+
20
+ if output_file
21
+ generate_persistent_log(output_file, merged_options)
22
+ else
23
+ generate_temporary_log(merged_options)
24
+ end
25
+ end
26
+
27
+ def available_presets
28
+ []
29
+ end
30
+
31
+ def interactive_generate
32
+ unless $stdin.tty?
33
+ raise "Interactive mode requires a terminal (TTY). Use --generate-log with presets instead."
34
+ end
35
+
36
+ puts "Interactive log generation for #{vcs_name}"
37
+ puts "Repository: #{@repository_path}"
38
+ puts
39
+
40
+ preset = choose_preset
41
+ options = gather_custom_options(preset)
42
+
43
+ save_log = ask_yes_no("Save log to file for future use?")
44
+
45
+ if save_log
46
+ default_filename = default_log_filename
47
+ filename = ask_string("Log filename", default_filename)
48
+ generate_log(filename, **options)
49
+ else
50
+ generate_log(nil, **options)
51
+ end
52
+ end
53
+
54
+ def interactive_generate_for_analysis(analysis_name, analysis_options = {})
55
+ unless $stdin.tty?
56
+ raise "Interactive mode requires a terminal (TTY). Use --generate-log with presets instead."
57
+ end
58
+
59
+ puts "Log generation for #{vcs_name}"
60
+ puts "Repository: #{@repository_path}"
61
+ puts "Analysis: #{RubyMaat::AnalysisPresets.analysis_description(analysis_name)}"
62
+ puts
63
+
64
+ # Get analysis-specific presets
65
+ presets = RubyMaat::AnalysisPresets.presets_for_analysis(analysis_name)
66
+
67
+ if presets.empty?
68
+ puts "No presets available for #{analysis_name}"
69
+ options = gather_custom_options({})
70
+ else
71
+ preset_options = choose_analysis_preset(analysis_name, presets)
72
+ options = gather_custom_options(preset_options)
73
+ end
74
+
75
+ # Merge with any analysis-specific options
76
+ options.merge!(analysis_options)
77
+
78
+ save_log = ask_yes_no("Save log to file for future use?", false)
79
+
80
+ if save_log
81
+ default_filename = "#{analysis_name}_#{default_log_filename}"
82
+ filename = ask_string("Log filename", default_filename)
83
+ generate_log(filename, **options)
84
+ else
85
+ generate_log(nil, **options)
86
+ end
87
+ end
88
+
89
+ protected
90
+
91
+ def vcs_name
92
+ self.class.name.split("::").last.sub("Generator", "").downcase
93
+ end
94
+
95
+ def validate_repository!
96
+ raise ArgumentError, "Repository path does not exist: #{@repository_path}" unless Dir.exist?(@repository_path)
97
+ end
98
+
99
+ def build_command(options)
100
+ raise NotImplementedError, "Subclasses must implement build_command"
101
+ end
102
+
103
+ def execute_command(command)
104
+ # Show command preview and ask for confirmation
105
+ show_command_preview(command)
106
+
107
+ puts "Executing: #{command}" if @options[:verbose]
108
+
109
+ # Use proper subprocess execution to prevent command injection
110
+ require "open3"
111
+
112
+ # Change to repository directory and execute command safely
113
+ result, error, status = Open3.capture3(command, chdir: @repository_path)
114
+ if status.exitstatus != 0
115
+ combined_output = result + error
116
+ raise "Command failed with exit code #{status.exitstatus}: #{combined_output}"
117
+ end
118
+
119
+ result
120
+ end
121
+
122
+ def default_log_filename
123
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
124
+ "#{vcs_name}_log_#{timestamp}.log"
125
+ end
126
+
127
+ private
128
+
129
+ def generate_persistent_log(output_file, options)
130
+ command = build_command(options)
131
+ output = execute_command(command)
132
+
133
+ File.write(output_file, output)
134
+ puts "Log generated: #{output_file}" unless @options[:quiet]
135
+
136
+ output_file
137
+ end
138
+
139
+ def generate_temporary_log(options)
140
+ command = build_command(options)
141
+ execute_command(command)
142
+ end
143
+
144
+ def choose_preset
145
+ presets = available_presets
146
+
147
+ if presets.empty?
148
+ puts "No presets available for #{vcs_name}"
149
+ return {}
150
+ end
151
+
152
+ puts "Available presets:"
153
+ presets.each_with_index do |(name, description), index|
154
+ puts " #{index + 1}. #{name} - #{description[:description]}"
155
+ end
156
+ puts " #{presets.length + 1}. Custom - Enter custom options"
157
+
158
+ choice = ask_integer("Choose preset", 1, presets.length + 1)
159
+
160
+ if choice <= presets.length
161
+ preset_name = presets.keys[choice - 1]
162
+ presets[preset_name][:options]
163
+ else
164
+ {}
165
+ end
166
+ end
167
+
168
+ def choose_analysis_preset(analysis_name, presets)
169
+ puts "Available presets for #{analysis_name}:"
170
+ presets.each_with_index do |(name, config), index|
171
+ puts " #{index + 1}. #{name} - #{config[:description]}"
172
+ end
173
+ puts " #{presets.length + 1}. Custom - Enter custom options"
174
+
175
+ choice = ask_integer("Choose preset", 1, presets.length + 1)
176
+
177
+ if choice <= presets.length
178
+ preset_name = presets.keys[choice - 1]
179
+ presets[preset_name]
180
+ else
181
+ {}
182
+ end
183
+ end
184
+
185
+ def gather_custom_options(base_options)
186
+ options = base_options.dup
187
+
188
+ puts "\nCustom options (press Enter to keep default):"
189
+
190
+ if supports_date_filtering?
191
+ since_date = ask_string("Since date (YYYY-MM-DD)", options[:since])
192
+ options[:since] = since_date unless since_date.empty?
193
+
194
+ until_date = ask_string("Until date (YYYY-MM-DD)", options[:until])
195
+ options[:until] = until_date unless until_date.empty?
196
+ end
197
+
198
+ gather_vcs_specific_options(options)
199
+ end
200
+
201
+ def gather_vcs_specific_options(options)
202
+ options
203
+ end
204
+
205
+ def supports_date_filtering?
206
+ true
207
+ end
208
+
209
+ def ask_string(prompt, default = nil)
210
+ default_text = default ? " [#{default}]" : ""
211
+ print "#{prompt}#{default_text}: "
212
+ response = $stdin.gets
213
+ return default || "" if response.nil?
214
+ response = response.chomp
215
+ response.empty? ? (default || "") : response
216
+ end
217
+
218
+ def ask_integer(prompt, min = nil, max = nil)
219
+ attempts = 0
220
+ max_attempts = 10
221
+
222
+ loop do
223
+ attempts += 1
224
+ if attempts > max_attempts
225
+ raise "Too many invalid attempts. Exiting interactive mode."
226
+ end
227
+
228
+ response = ask_string(prompt)
229
+
230
+ # Handle empty response or non-interactive mode
231
+ if response.nil? || response.empty?
232
+ puts "Please enter a valid number"
233
+ next
234
+ end
235
+
236
+ begin
237
+ value = Integer(response)
238
+ return value if (min.nil? || value >= min) && (max.nil? || value <= max)
239
+ puts "Please enter a number between #{min} and #{max}"
240
+ rescue ArgumentError
241
+ puts "Please enter a valid number"
242
+ end
243
+ end
244
+ end
245
+
246
+ def ask_yes_no(prompt, default = nil)
247
+ default_text = case default
248
+ when true then " [Y/n]"
249
+ when false then " [y/N]"
250
+ else " [y/n]"
251
+ end
252
+
253
+ loop do
254
+ response = ask_string("#{prompt}#{default_text}").downcase
255
+
256
+ case response
257
+ when "y", "yes"
258
+ return true
259
+ when "n", "no"
260
+ return false
261
+ when ""
262
+ return default unless default.nil?
263
+ end
264
+
265
+ puts "Please enter 'y' or 'n'"
266
+ end
267
+ end
268
+
269
+ def show_command_preview(command)
270
+ # Skip preview in non-interactive modes, when quiet is set, or during tests
271
+ return if @options[:quiet] || !$stdin.tty? || test_environment?
272
+
273
+ puts "\n" + "=" * 60
274
+ puts "COMMAND PREVIEW"
275
+ puts "=" * 60
276
+ puts "Repository: #{@repository_path}"
277
+ puts "Command: #{command}"
278
+ puts "=" * 60
279
+ puts "\nPress Enter to execute this command..."
280
+ $stdin.gets
281
+ end
282
+
283
+ def test_environment?
284
+ # Check if we're in a test environment
285
+ !!(defined?(RSpec) || ENV["RUBY_MAAT_TEST"] == "true" || ENV["RSPEC_CORE_PID"])
286
+ end
287
+ end
288
+ end
289
+ end