howzit 2.1.18 → 2.1.22

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/howzit/prompt.rb CHANGED
@@ -83,21 +83,28 @@ module Howzit
83
83
  ## number of options, anything
84
84
  ## else gets max height for
85
85
  ## terminal)
86
+ ## @param query [String] The search term to display in prompt
86
87
  ##
87
88
  ## @return [Array] the selected results
88
89
  ##
89
- def choose(matches, height: :auto)
90
- return [] if !$stdout.isatty || matches.count.zero?
90
+ def choose(matches, height: :auto, query: nil)
91
+ return [] if matches.count.zero?
92
+ return matches if matches.count == 1
93
+ return [] unless $stdout.isatty
91
94
 
92
95
  if Util.command_exist?('fzf')
93
96
  height = height == :auto ? matches.count + 3 : TTY::Screen.rows
94
97
 
95
- settings = fzf_options(height)
98
+ settings = fzf_options(height, query: query)
96
99
  res = `echo #{Shellwords.escape(matches.join("\n"))} | fzf #{settings.join(' ')}`.strip
97
100
  return fzf_result(res)
98
101
  end
99
102
 
100
- tty_menu(matches)
103
+ if Util.command_exist?('gum')
104
+ return gum_choose(matches, query: query, multi: true)
105
+ end
106
+
107
+ tty_menu(matches, query: query)
101
108
  end
102
109
 
103
110
  def fzf_result(res)
@@ -108,7 +115,12 @@ module Howzit
108
115
  res.split(/\n/)
109
116
  end
110
117
 
111
- def fzf_options(height)
118
+ def fzf_options(height, query: nil)
119
+ prompt = if query
120
+ "Select a topic for \\`#{query}\\` > "
121
+ else
122
+ 'Select a topic > '
123
+ end
112
124
  [
113
125
  '-0',
114
126
  '-1',
@@ -116,7 +128,7 @@ module Howzit
116
128
  "--height=#{height}",
117
129
  '--header="Tab: add selection, ctrl-a/d: (de)select all, return: display/run"',
118
130
  '--bind ctrl-a:select-all,ctrl-d:deselect-all,ctrl-t:toggle-all',
119
- '--prompt="Select a topic > "',
131
+ "--prompt=\"#{prompt}\"",
120
132
  %(--preview="howzit --no-pager --header-format block --no-color --default --multiple first {}")
121
133
  ]
122
134
  end
@@ -125,8 +137,9 @@ module Howzit
125
137
  ## Display a numeric menu on the TTY
126
138
  ##
127
139
  ## @param matches The matches from which to select
140
+ ## @param query [String] The search term to display
128
141
  ##
129
- def tty_menu(matches)
142
+ def tty_menu(matches, query: nil)
130
143
  return matches if matches.count == 1
131
144
 
132
145
  @stty_save = `stty -g`.chomp
@@ -136,6 +149,9 @@ module Howzit
136
149
  exit
137
150
  end
138
151
 
152
+ if query
153
+ puts "\nSelect a topic for `#{query}`:"
154
+ end
139
155
  options_list(matches)
140
156
  read_selection(matches)
141
157
  end
@@ -187,6 +203,190 @@ module Howzit
187
203
  end
188
204
  line == '' ? 1 : line.to_i
189
205
  end
206
+
207
+ ##
208
+ ## Multi-select menu for templates
209
+ ##
210
+ ## @param matches [Array] The options list
211
+ ## @param prompt_text [String] The prompt to display
212
+ ##
213
+ ## @return [Array] the selected results (can be empty)
214
+ ##
215
+ def choose_templates(matches, prompt_text: 'Select templates')
216
+ return [] if matches.count.zero?
217
+ return [] unless $stdout.isatty
218
+
219
+ if Util.command_exist?('fzf')
220
+ height = matches.count + 3
221
+ settings = fzf_template_options(height, prompt_text: prompt_text)
222
+
223
+ # Save terminal state before fzf
224
+ tty_state = `stty -g`.chomp
225
+ res = `echo #{Shellwords.escape(matches.join("\n"))} | fzf #{settings.join(' ')}`.strip
226
+ # Restore terminal state after fzf
227
+ system("stty #{tty_state}")
228
+
229
+ return res.empty? ? [] : res.split(/\n/)
230
+ end
231
+
232
+ if Util.command_exist?('gum')
233
+ return gum_choose(matches, prompt: prompt_text, multi: true, required: false)
234
+ end
235
+
236
+ text_template_input(matches)
237
+ end
238
+
239
+ ##
240
+ ## FZF options for template selection
241
+ ##
242
+ def fzf_template_options(height, prompt_text: 'Select templates')
243
+ [
244
+ '-0',
245
+ '-m',
246
+ "--height=#{height}",
247
+ '--header="Tab: add selection, ctrl-a/d: (de)select all, esc: skip, return: confirm"',
248
+ '--bind ctrl-a:select-all,ctrl-d:deselect-all,ctrl-t:toggle-all',
249
+ "--prompt=\"#{prompt_text} > \""
250
+ ]
251
+ end
252
+
253
+ ##
254
+ ## Text-based template input with fuzzy matching
255
+ ##
256
+ ## @param available [Array] Available template names
257
+ ##
258
+ ## @return [Array] Matched template names
259
+ ##
260
+ def text_template_input(available)
261
+ @stty_save = `stty -g`.chomp
262
+
263
+ trap('INT') do
264
+ system('stty', @stty_save)
265
+ exit
266
+ end
267
+
268
+ puts "\n{bw}Available templates:{x} #{available.join(', ')}".c
269
+ printf '{bw}Enter templates to include, comma-separated (return to skip):{x} '.c
270
+ input = Readline.readline('', true).strip
271
+
272
+ return [] if input.empty?
273
+
274
+ fuzzy_match_templates(input, available)
275
+ ensure
276
+ system('stty', @stty_save) if @stty_save
277
+ end
278
+
279
+ ##
280
+ ## Fuzzy match user input against available templates
281
+ ##
282
+ ## @param input [String] Comma-separated user input
283
+ ## @param available [Array] Available template names
284
+ ##
285
+ ## @return [Array] Matched template names
286
+ ##
287
+ def fuzzy_match_templates(input, available)
288
+ terms = input.split(',').map(&:strip).reject(&:empty?)
289
+ matched = []
290
+
291
+ terms.each do |term|
292
+ # Try exact match first (case-insensitive)
293
+ exact = available.find { |t| t.downcase == term.downcase }
294
+ if exact
295
+ matched << exact unless matched.include?(exact)
296
+ next
297
+ end
298
+
299
+ # Try fuzzy match using the same regex approach as topic matching
300
+ rx = term.to_rx
301
+ fuzzy = available.select { |t| t =~ rx }
302
+
303
+ # Prefer matches that start with the term
304
+ if fuzzy.length > 1
305
+ starts_with = fuzzy.select { |t| t.downcase.start_with?(term.downcase) }
306
+ fuzzy = starts_with unless starts_with.empty?
307
+ end
308
+
309
+ fuzzy.each { |t| matched << t unless matched.include?(t) }
310
+ end
311
+
312
+ matched
313
+ end
314
+
315
+ ##
316
+ ## Prompt for a single line of input
317
+ ##
318
+ ## @param prompt_text [String] The prompt to display
319
+ ## @param default [String] Default value if empty
320
+ ##
321
+ ## @return [String] the entered value
322
+ ##
323
+ def get_line(prompt_text, default: nil)
324
+ return (default || '') unless $stdout.isatty
325
+
326
+ if Util.command_exist?('gum')
327
+ result = gum_input(prompt_text, placeholder: default || '')
328
+ return result.empty? && default ? default : result
329
+ end
330
+
331
+ prompt_with_default = default ? "#{prompt_text} [#{default}]: " : "#{prompt_text}: "
332
+ result = Readline.readline(prompt_with_default, true).to_s.strip
333
+ result.empty? && default ? default : result
334
+ end
335
+
336
+ ##
337
+ ## Use gum for single or multi-select menu
338
+ ##
339
+ ## @param matches [Array] The options list
340
+ ## @param prompt [String] The prompt text
341
+ ## @param multi [Boolean] Allow multiple selections
342
+ ## @param required [Boolean] Require at least one selection
343
+ ## @param query [String] The search term for display
344
+ ##
345
+ ## @return [Array] Selected items
346
+ ##
347
+ def gum_choose(matches, prompt: nil, multi: false, required: true, query: nil)
348
+ prompt_text = prompt || (query ? "Select for '#{query}'" : 'Select')
349
+ args = ['gum', 'choose']
350
+ args << '--no-limit' if multi
351
+ args << "--header=#{Shellwords.escape(prompt_text)}"
352
+ args << '--cursor.foreground=6'
353
+ args << '--selected.foreground=2'
354
+
355
+ tty_state = `stty -g`.chomp
356
+ res = `echo #{Shellwords.escape(matches.join("\n"))} | #{args.join(' ')}`.strip
357
+ system("stty #{tty_state}")
358
+
359
+ if res.empty?
360
+ if required
361
+ Howzit.console.info 'Cancelled'
362
+ Process.exit 0
363
+ end
364
+ return []
365
+ end
366
+
367
+ res.split(/\n/)
368
+ end
369
+
370
+ ##
371
+ ## Use gum for text input
372
+ ##
373
+ ## @param prompt_text [String] The prompt to display
374
+ ## @param placeholder [String] Placeholder text
375
+ ##
376
+ ## @return [String] The entered value
377
+ ##
378
+ def gum_input(prompt_text, placeholder: '')
379
+ args = ['gum', 'input']
380
+ args << "--header=#{Shellwords.escape(prompt_text)}"
381
+ args << "--placeholder=#{Shellwords.escape(placeholder)}" unless placeholder.empty?
382
+ args << '--cursor.foreground=6'
383
+
384
+ tty_state = `stty -g`.chomp
385
+ res = `#{args.join(' ')}`.strip
386
+ system("stty #{tty_state}")
387
+
388
+ res
389
+ end
190
390
  end
191
391
  end
192
392
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Howzit
4
+ # Formatter for task run summaries
5
+ module RunReport
6
+ module_function
7
+
8
+ def reset
9
+ Howzit.run_log = []
10
+ end
11
+
12
+ def log(entry)
13
+ Howzit.run_log = [] if Howzit.run_log.nil?
14
+ Howzit.run_log << entry
15
+ end
16
+
17
+ def entries
18
+ Howzit.run_log || []
19
+ end
20
+
21
+ def format
22
+ return '' if entries.empty?
23
+
24
+ lines = entries.map { |entry| format_line(entry, Howzit.multi_topic_run) }
25
+ lines.map! { |line| line.rstrip }
26
+ widths = lines.map { |line| line.uncolor.length }
27
+ width = widths.max
28
+ top = '=' * width
29
+ bottom = '-' * width
30
+ output_lines = [top] + lines + [bottom]
31
+ result = output_lines.join("\n")
32
+ result = result.gsub(/\n[ \t]+\n/, "\n")
33
+ result.gsub(/\n{2,}/, "\n")
34
+ end
35
+
36
+ def format_line(entry, prefix_topic)
37
+ bullet_start = '{mb}- [{x}'
38
+ bullet_end = '{mb}] {x}'
39
+ symbol = entry[:success] ? '{bg}✓{x}' : '{br}X{x}'
40
+ parts = []
41
+ parts << "#{bullet_start}#{symbol}#{bullet_end}"
42
+ parts << "{bl}#{entry[:topic]}{x}: " if prefix_topic && entry[:topic] && !entry[:topic].empty?
43
+ parts << "{by}#{entry[:task]}{x}"
44
+ unless entry[:success]
45
+ reason = entry[:exit_status] ? "exit code #{entry[:exit_status]}" : 'failed'
46
+ parts << " {br}(Failed: #{reason}){x}"
47
+ end
48
+ parts.join.c
49
+ end
50
+ end
51
+ end
@@ -183,7 +183,9 @@ module Howzit
183
183
 
184
184
  # Just strip out color codes when requested
185
185
  def uncolor
186
- gsub(/\e\[[\d;]+m/, '').gsub(/\e\]1337;SetMark/, '')
186
+ # force UTF-8 and remove invalid characters, then remove color codes
187
+ # and iTerm markers
188
+ gsub(Howzit::Color::COLORED_REGEXP, "").gsub(/\e\]1337;SetMark/, "")
187
189
  end
188
190
 
189
191
  # Wrap text at a specified width.
data/lib/howzit/task.rb CHANGED
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'English'
4
+
3
5
  module Howzit
4
6
  # Task object
5
7
  class Task
6
- attr_reader :type, :title, :action, :arguments, :parent, :optional, :default
8
+ attr_reader :type, :title, :action, :arguments, :parent, :optional, :default, :last_status
7
9
 
8
10
  ##
9
11
  ## Initialize a Task object
@@ -31,6 +33,7 @@ module Howzit
31
33
 
32
34
  @optional = optional
33
35
  @default = default
36
+ @last_status = nil
34
37
  end
35
38
 
36
39
  ##
@@ -68,6 +71,7 @@ module Howzit
68
71
  script.unlink
69
72
  end
70
73
 
74
+ update_last_status(res ? 0 : 1)
71
75
  res
72
76
  end
73
77
 
@@ -86,6 +90,7 @@ module Howzit
86
90
  Howzit.console.info("#{@prefix}{by}Running tasks from {bw}#{matches[0].title}{x}".c)
87
91
  output.concat(matches[0].run(nested: true))
88
92
  Howzit.console.info("{by}End include: #{matches[0].tasks.count} tasks{x}".c)
93
+ @last_status = nil
89
94
  [output, matches[0].tasks.count]
90
95
  end
91
96
 
@@ -96,7 +101,9 @@ module Howzit
96
101
  title = Howzit.options[:show_all_code] ? @action : @title
97
102
  Howzit.console.info("#{@prefix}{bg}Running {bw}#{title}{x}".c)
98
103
  ENV['HOWZIT_SCRIPTS'] = File.expand_path('~/.config/howzit/scripts')
99
- system(@action)
104
+ res = system(@action)
105
+ update_last_status(res ? 0 : 1)
106
+ res
100
107
  end
101
108
 
102
109
  ##
@@ -106,6 +113,7 @@ module Howzit
106
113
  title = Howzit.options[:show_all_code] ? @action : @title
107
114
  Howzit.console.info("#{@prefix}{bg}Copied {bw}#{title}{bg} to clipboard{x}".c)
108
115
  Util.os_copy(@action)
116
+ @last_status = 0
109
117
  true
110
118
  end
111
119
 
@@ -127,12 +135,23 @@ module Howzit
127
135
  run_copy
128
136
  when :open
129
137
  Util.os_open(@action)
138
+ @last_status = 0
139
+ true
130
140
  end
131
141
  end
132
142
 
133
143
  [output, tasks, res]
134
144
  end
135
145
 
146
+ def update_last_status(default = nil)
147
+ status = if defined?($CHILD_STATUS) && $CHILD_STATUS
148
+ $CHILD_STATUS.exitstatus
149
+ else
150
+ default
151
+ end
152
+ @last_status = status
153
+ end
154
+
136
155
  ##
137
156
  ## Output terminal-formatted list item
138
157
  ##
data/lib/howzit/topic.rb CHANGED
@@ -104,6 +104,8 @@ module Howzit
104
104
 
105
105
  break unless Howzit.options[:force]
106
106
  end
107
+
108
+ log_task_result(task, success)
107
109
  end
108
110
 
109
111
  total = "{bw}#{@results[:total]}{by} #{@results[:total] == 1 ? 'task' : 'tasks'}".c
@@ -119,7 +121,7 @@ module Howzit
119
121
  Howzit.console.warn "{r}--run: No {br}@directive{xr} found in {bw}#{@title}{x}".c
120
122
  end
121
123
 
122
- output.push(@results[:message]) if Howzit.options[:log_level] < 2 && !nested
124
+ output.push(@results[:message]) if Howzit.options[:log_level] < 2 && !nested && !Howzit.options[:run]
123
125
 
124
126
  puts TTY::Box.frame("{bw}#{@postreqs.join("\n\n").wrap(cols - 4)}{x}".c, width: cols) unless @postreqs.empty?
125
127
 
@@ -324,6 +326,27 @@ module Howzit
324
326
  ##
325
327
  ## @return [Array] array of Task objects
326
328
  ##
329
+ def log_task_result(task, success)
330
+ return unless Howzit.options[:run]
331
+ return if task.type == :include
332
+
333
+ Howzit.run_log ||= []
334
+
335
+ title = (task.title || '').strip
336
+ if title.empty?
337
+ action = (task.action || '').strip
338
+ title = action.split(/\n/).first.to_s.strip
339
+ end
340
+ title = task.type.to_s.capitalize if title.nil? || title.empty?
341
+
342
+ Howzit.run_log << {
343
+ topic: @title,
344
+ task: title,
345
+ success: success ? true : false,
346
+ exit_status: task.last_status
347
+ }
348
+ end
349
+
327
350
  def gather_tasks
328
351
  runnable = []
329
352
  @prereqs = @content.scan(/(?<=@before\n).*?(?=\n@end)/im).map(&:strip)
data/lib/howzit/util.rb CHANGED
@@ -145,7 +145,11 @@ module Howzit
145
145
  end
146
146
 
147
147
  read_io.close
148
- write_io.write(text)
148
+ begin
149
+ write_io.write(text)
150
+ rescue Errno::EPIPE
151
+ # User quit pager before we finished writing, ignore
152
+ end
149
153
  write_io.close
150
154
 
151
155
  _, status = Process.waitpid2(pid)
@@ -3,5 +3,5 @@
3
3
  # Primary module for this gem.
4
4
  module Howzit
5
5
  # Current Howzit version.
6
- VERSION = '2.1.18'
6
+ VERSION = '2.1.22'
7
7
  end
data/lib/howzit.rb CHANGED
@@ -41,6 +41,7 @@ require_relative 'howzit/config'
41
41
  require_relative 'howzit/task'
42
42
  require_relative 'howzit/topic'
43
43
  require_relative 'howzit/buildnote'
44
+ require_relative 'howzit/run_report'
44
45
 
45
46
  require 'tty/screen'
46
47
  require 'tty/box'
@@ -49,7 +50,7 @@ require 'tty/box'
49
50
  # Main module for howzit
50
51
  module Howzit
51
52
  class << self
52
- attr_accessor :arguments, :named_arguments, :cli_args
53
+ attr_accessor :arguments, :named_arguments, :cli_args, :run_log, :multi_topic_run
53
54
 
54
55
  ##
55
56
  ## Holds a Configuration object with methods and a @settings hash
@@ -88,10 +89,18 @@ module Howzit
88
89
  @console ||= Howzit::ConsoleLogger.new(options[:log_level])
89
90
  end
90
91
 
92
+ def run_log
93
+ @run_log ||= []
94
+ end
95
+
96
+ def multi_topic_run
97
+ @multi_topic_run ||= false
98
+ end
99
+
91
100
  def has_read_upstream
92
101
  @has_read_upstream ||= false
93
102
  end
94
103
 
95
- attr_writer :has_read_upstream
104
+ attr_writer :has_read_upstream, :run_log
96
105
  end
97
106
  end
@@ -62,6 +62,12 @@ describe Howzit::BuildNote do
62
62
  expect(matches.count).to eq 0
63
63
  end
64
64
 
65
+ it "matches topics containing colon even without space" do
66
+ matches = how.find_topic('git:clean')
67
+ expect(matches.count).to eq 1
68
+ expect(matches[0].title).to eq 'Git: Clean Repo'
69
+ end
70
+
65
71
  it "Handles multiple matches with best match" do
66
72
  Howzit.options[:matching] = 'fuzzy'
67
73
  Howzit.options[:multiple_matches] = :best
@@ -70,12 +76,164 @@ describe Howzit::BuildNote do
70
76
  end
71
77
  end
72
78
 
79
+ describe ".find_topic_exact" do
80
+ it "finds exact whole-word match" do
81
+ matches = how.find_topic_exact('Topic Tropic')
82
+ expect(matches.count).to eq 1
83
+ expect(matches[0].title).to eq 'Topic Tropic'
84
+ end
85
+
86
+ it "finds exact match case-insensitively" do
87
+ matches = how.find_topic_exact('topic tropic')
88
+ expect(matches.count).to eq 1
89
+ expect(matches[0].title).to eq 'Topic Tropic'
90
+ end
91
+
92
+ it "does not match partial phrases" do
93
+ matches = how.find_topic_exact('topic trop')
94
+ expect(matches.count).to eq 0
95
+ end
96
+
97
+ it "does not match single word when phrase has multiple words" do
98
+ matches = how.find_topic_exact('topic')
99
+ expect(matches.count).to eq 0
100
+ end
101
+
102
+ it "matches single-word topics" do
103
+ matches = how.find_topic_exact('Happy Bgagngagnga')
104
+ expect(matches.count).to eq 1
105
+ expect(matches[0].title).to eq 'Happy Bgagngagnga'
106
+ end
107
+
108
+ it "matches topics with colons" do
109
+ matches = how.find_topic_exact('Git: Clean Repo')
110
+ expect(matches.count).to eq 1
111
+ expect(matches[0].title).to eq 'Git: Clean Repo'
112
+ end
113
+ end
114
+
73
115
  describe ".topics" do
74
- it "contains 4 topics" do
75
- expect(how.list_topics.count).to eq 4
116
+ it "contains 7 topics" do
117
+ expect(how.list_topics.count).to eq 7
76
118
  end
77
119
  it "outputs a newline-separated string for completion" do
78
- expect(how.list_completions.scan(/\n/).count).to eq 3
120
+ expect(how.list_completions.scan(/\n/).count).to eq 6
121
+ end
122
+ end
123
+
124
+ describe "#topic_search_terms_from_cli" do
125
+ after { Howzit.cli_args = [] }
126
+
127
+ it "respects separators found inside topics" do
128
+ Howzit.cli_args = ['git:clean:blog:update post']
129
+ expect(how.send(:topic_search_terms_from_cli)).to eq(['git:clean', 'blog:update post'])
130
+ end
131
+
132
+ it "keeps comma inside matching topics" do
133
+ Howzit.cli_args = ['release, deploy,topic balogna']
134
+ expect(how.send(:topic_search_terms_from_cli)).to eq(['release, deploy', 'topic balogna'])
135
+ end
136
+ end
137
+
138
+ describe "#collect_topic_matches" do
139
+ before do
140
+ Howzit.options[:multiple_matches] = :first
141
+ end
142
+
143
+ it "collects matches for multiple search terms" do
144
+ search_terms = ['topic tropic', 'topic banana']
145
+ output = []
146
+ matches = how.send(:collect_topic_matches, search_terms, output)
147
+ expect(matches.count).to eq 2
148
+ expect(matches.map(&:title)).to include('Topic Tropic', 'Topic Banana')
149
+ end
150
+
151
+ it "prefers exact matches over fuzzy matches" do
152
+ # 'Topic Banana' should exact-match, not fuzzy match to multiple
153
+ search_terms = ['topic banana']
154
+ output = []
155
+ matches = how.send(:collect_topic_matches, search_terms, output)
156
+ expect(matches.count).to eq 1
157
+ expect(matches[0].title).to eq 'Topic Banana'
158
+ end
159
+
160
+ it "falls back to fuzzy match when no exact match" do
161
+ Howzit.options[:matching] = 'fuzzy'
162
+ search_terms = ['trpc'] # fuzzy for 'tropic'
163
+ output = []
164
+ matches = how.send(:collect_topic_matches, search_terms, output)
165
+ expect(matches.count).to eq 1
166
+ expect(matches[0].title).to eq 'Topic Tropic'
167
+ end
168
+
169
+ it "adds error message for unmatched terms" do
170
+ search_terms = ['nonexistent topic xyz']
171
+ output = []
172
+ matches = how.send(:collect_topic_matches, search_terms, output)
173
+ expect(matches.count).to eq 0
174
+ expect(output.join).to match(/no topic match found/i)
175
+ end
176
+
177
+ it "collects multiple topics from comma-separated input" do
178
+ Howzit.cli_args = ['topic tropic,topic banana']
179
+ search_terms = how.send(:topic_search_terms_from_cli)
180
+ output = []
181
+ matches = how.send(:collect_topic_matches, search_terms, output)
182
+ expect(matches.count).to eq 2
183
+ Howzit.cli_args = []
184
+ end
185
+ end
186
+
187
+ describe "#smart_split_topics" do
188
+ it "splits on comma when not part of topic title" do
189
+ result = how.send(:smart_split_topics, 'topic tropic,topic banana')
190
+ expect(result).to eq(['topic tropic', 'topic banana'])
191
+ end
192
+
193
+ it "preserves comma when part of topic title" do
194
+ result = how.send(:smart_split_topics, 'release, deploy,topic banana')
195
+ expect(result).to eq(['release, deploy', 'topic banana'])
196
+ end
197
+
198
+ it "preserves colon when part of topic title" do
199
+ result = how.send(:smart_split_topics, 'git:clean,blog:update post')
200
+ expect(result).to eq(['git:clean', 'blog:update post'])
201
+ end
202
+
203
+ it "handles mixed separators correctly" do
204
+ result = how.send(:smart_split_topics, 'git:clean:topic tropic')
205
+ expect(result).to eq(['git:clean', 'topic tropic'])
206
+ end
207
+ end
208
+
209
+ describe "#parse_template_required_vars" do
210
+ let(:template_with_required) do
211
+ Tempfile.new(['template', '.md']).tap do |f|
212
+ f.write("required: repo_url, author\n\n# Template\n\n## Section")
213
+ f.close
214
+ end
215
+ end
216
+
217
+ let(:template_without_required) do
218
+ Tempfile.new(['template', '.md']).tap do |f|
219
+ f.write("# Template\n\n## Section")
220
+ f.close
221
+ end
222
+ end
223
+
224
+ after do
225
+ template_with_required.unlink
226
+ template_without_required.unlink
227
+ end
228
+
229
+ it "parses required variables from template metadata" do
230
+ vars = how.send(:parse_template_required_vars, template_with_required.path)
231
+ expect(vars).to eq(['repo_url', 'author'])
232
+ end
233
+
234
+ it "returns empty array when no required metadata" do
235
+ vars = how.send(:parse_template_required_vars, template_without_required.path)
236
+ expect(vars).to eq([])
79
237
  end
80
238
  end
81
239
  end