howzit 2.1.27 → 2.1.29

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.
@@ -290,22 +290,14 @@ module Howzit
290
290
 
291
291
  title = File.basename(Dir.pwd)
292
292
  # prompt = TTY::Prompt.new
293
- if default
294
- input = title
295
- else
296
- title = Prompt.get_line('{bw}Project name{x}'.c, default: title)
297
- end
293
+ title = Prompt.get_line('{bw}Project name{x}'.c, default: title) unless default
298
294
  summary = ''
299
- unless default
300
- summary = Prompt.get_line('{bw}Project summary{x}'.c)
301
- end
295
+ summary = Prompt.get_line('{bw}Project summary{x}'.c) unless default
302
296
 
303
297
  # Template selection
304
298
  selected_templates = []
305
299
  template_metadata = {}
306
- unless default
307
- selected_templates, template_metadata = select_templates_for_note(title)
308
- end
300
+ selected_templates, template_metadata = select_templates_for_note(title) unless default
309
301
 
310
302
  fname = 'buildnotes.md'
311
303
  unless default
@@ -314,9 +306,7 @@ module Howzit
314
306
 
315
307
  # Build metadata section
316
308
  metadata_lines = []
317
- unless selected_templates.empty?
318
- metadata_lines << "template: #{selected_templates.join(',')}"
319
- end
309
+ metadata_lines << "template: #{selected_templates.join(',')}" unless selected_templates.empty?
320
310
  template_metadata.each do |key, value|
321
311
  metadata_lines << "#{key}: #{value}"
322
312
  end
@@ -386,7 +376,7 @@ module Howzit
386
376
  ##
387
377
  ## @return [Array<Array, Hash>] Array of [selected_template_names, required_vars_hash]
388
378
  ##
389
- def select_templates_for_note(project_title)
379
+ def select_templates_for_note(_project_title)
390
380
  template_dir = Howzit.config.template_folder
391
381
  template_glob = File.join(template_dir, '*.md')
392
382
  template_files = Dir.glob(template_glob)
@@ -676,7 +666,7 @@ module Howzit
676
666
  ## @return [String] file path
677
667
  ##
678
668
  def glob_note
679
- Dir.glob('*.{txt,md,markdown}').select(&:build_note?).sort[0]
669
+ Dir.glob('*.{txt,md,markdown}').select(&:build_note?).min
680
670
  end
681
671
 
682
672
  ##
@@ -790,7 +780,7 @@ module Howzit
790
780
  title = "#{short_path}:#{title}"
791
781
  end
792
782
 
793
- topic = Topic.new(title, prefix + lines.join("\n").strip.render_template(@metadata))
783
+ topic = Topic.new(title, prefix + lines.join("\n").strip.render_template(@metadata), @metadata)
794
784
 
795
785
  topics.push(topic)
796
786
  end
@@ -879,7 +869,21 @@ module Howzit
879
869
  ## single topic
880
870
  ##
881
871
  def process_topic(topic, run, single: false)
882
- new_topic = topic.is_a?(String) ? find_topic(topic)[0] : topic.dup
872
+ if topic.is_a?(String)
873
+ matches = find_topic(topic)
874
+ new_topic = matches.empty? ? nil : matches[0]
875
+ else
876
+ new_topic = begin
877
+ topic.dup
878
+ rescue StandardError
879
+ topic
880
+ end
881
+ end
882
+
883
+ if new_topic.nil?
884
+ Howzit.console.warn "{br}ERROR:{xr} Topic not found or invalid: {bw}#{topic.is_a?(String) ? topic : topic.inspect}{x}".c
885
+ return ''
886
+ end
883
887
 
884
888
  output = if run
885
889
  new_topic.run
@@ -946,13 +950,67 @@ module Howzit
946
950
  when :all
947
951
  topic_matches.concat(matches.sort_by(&:title))
948
952
  else
949
- topic_matches.concat(Prompt.choose(matches.map(&:title), height: :max, query: Howzit.options[:grep]))
953
+ selected_titles = Prompt.choose(matches.map(&:title), height: :max, query: Howzit.options[:grep])
954
+ # Convert selected titles back to topic objects
955
+ selected_titles.each do |title|
956
+ matched_topic = matches.find { |t| t.title == title }
957
+ topic_matches.push(matched_topic) if matched_topic
958
+ end
950
959
  end
960
+ topic_matches.compact! # Remove any nil values
951
961
  process_topic_matches(topic_matches, output)
952
962
  elsif Howzit.options[:choose]
953
963
  topic_matches = []
954
964
  titles = Prompt.choose(list_topics, height: :max)
955
- titles.each { |title| topic_matches.push(find_topic(title)[0]) }
965
+ titles.each do |selected_title|
966
+ selected_title = selected_title.strip
967
+ matched_topic = nil
968
+
969
+ # First, try to match by reconstructing the formatted title exactly as list_topics does
970
+ @topics.each do |topic|
971
+ formatted_title = topic.title.dup
972
+ formatted_title += "(#{topic.named_args.keys.join(', ')})" unless topic.named_args.empty?
973
+ formatted_title = formatted_title.strip
974
+
975
+ # Normalize both titles for comparison (remove extra whitespace, normalize case)
976
+ normalized_formatted = formatted_title.downcase.gsub(/\s+/, ' ')
977
+ normalized_selected = selected_title.downcase.gsub(/\s+/, ' ')
978
+
979
+ if formatted_title == selected_title || normalized_formatted == normalized_selected
980
+ matched_topic = topic
981
+ break
982
+ end
983
+ end
984
+
985
+ # If still not found, try matching by base title (without args)
986
+ unless matched_topic
987
+ clean_selected = selected_title.sub(/ *\([^)]*\) *$/, '').strip
988
+ matched_topic = @topics.find do |topic|
989
+ base_title = topic.title.strip
990
+ base_title.downcase == clean_selected.downcase || base_title == clean_selected
991
+ end
992
+ end
993
+
994
+ # Last resort: use find_topic with the cleaned title
995
+ unless matched_topic
996
+ clean_selected = selected_title.sub(/ *\([^)]*\) *$/, '').strip
997
+ matches = find_topic(clean_selected)
998
+ matched_topic = matches[0] unless matches.empty?
999
+ end
1000
+
1001
+ if matched_topic
1002
+ topic_matches.push(matched_topic)
1003
+ else
1004
+ Howzit.console.warn "{br}WARNING:{xr} Could not find topic matching: {bw}#{selected_title}{x}".c
1005
+ end
1006
+ end
1007
+
1008
+ topic_matches.compact! # Remove any nil values
1009
+ if topic_matches.empty?
1010
+ output.push(%({bR}ERROR:{xr} No valid topics found from selection{x}\n).c)
1011
+ Util.show(output.join("\n"), { color: true, highlight: false, paginate: false, wrap: 0 })
1012
+ Process.exit 1
1013
+ end
956
1014
  process_topic_matches(topic_matches, output)
957
1015
  elsif !Howzit.cli_args.empty?
958
1016
  # Check if first arg is "default"
@@ -964,23 +1022,21 @@ module Howzit
964
1022
  topic_matches = collect_topic_matches(search, output)
965
1023
  process_topic_matches(topic_matches, output)
966
1024
  end
967
- else
1025
+ elsif Howzit.options[:run]
968
1026
  # No arguments
969
- if Howzit.options[:run]
970
- # Check for default metadata when running with no args
971
- if @metadata.key?('default')
972
- process_default_metadata(output)
973
- else
974
- Howzit.run_log = []
975
- Howzit.multi_topic_run = topics.length > 1
976
- topics.each { |k| output.push(process_topic(k, false, single: false)) }
977
- finalize_output(output)
978
- end
1027
+ # Check for default metadata when running with no args
1028
+ if @metadata.key?('default')
1029
+ process_default_metadata(output)
979
1030
  else
980
- # Show all topics
1031
+ Howzit.run_log = []
1032
+ Howzit.multi_topic_run = topics.length > 1
981
1033
  topics.each { |k| output.push(process_topic(k, false, single: false)) }
982
1034
  finalize_output(output)
983
1035
  end
1036
+ else
1037
+ # Show all topics
1038
+ topics.each { |k| output.push(process_topic(k, false, single: false)) }
1039
+ finalize_output(output)
984
1040
  end
985
1041
  end
986
1042
 
@@ -996,7 +1052,12 @@ module Howzit
996
1052
  def collect_topic_matches(search_terms, output)
997
1053
  all_matches = []
998
1054
 
1055
+ # If no search terms, return empty (don't match all topics)
1056
+ return [] if search_terms.nil? || search_terms.empty?
1057
+
999
1058
  search_terms.each do |s|
1059
+ next if s.nil? || s.strip.empty?
1060
+
1000
1061
  # First check for exact whole-word matches
1001
1062
  exact_matches = find_topic_exact(s)
1002
1063
 
@@ -1024,7 +1085,7 @@ module Howzit
1024
1085
  ##
1025
1086
  ## @return [Array] Array of matched topics
1026
1087
  ##
1027
- def resolve_fuzzy_matches(search_term, output)
1088
+ def resolve_fuzzy_matches(search_term, _output)
1028
1089
  matches = find_topic(search_term)
1029
1090
 
1030
1091
  return [] if matches.empty?
@@ -1033,17 +1094,18 @@ module Howzit
1033
1094
  when :first
1034
1095
  [matches[0]]
1035
1096
  when :best
1036
- [matches.sort_by { |a| [a.title.comp_distance(search_term), a.title.length] }.first]
1097
+ [matches.min_by { |a| [a.title.comp_distance(search_term), a.title.length] }]
1037
1098
  when :all
1038
1099
  matches
1039
1100
  else
1040
1101
  titles = matches.map(&:title)
1041
1102
  res = Prompt.choose(titles, query: search_term)
1042
- old_matching = Howzit.options[:matching]
1043
- Howzit.options[:matching] = 'exact'
1044
- selected = res.flat_map { |title| find_topic(title) }
1045
- Howzit.options[:matching] = old_matching
1046
- selected
1103
+ # Convert selected titles back to topic objects from the original matches
1104
+ res.flat_map do |title|
1105
+ matched_topic = matches.find { |t| t.title == title }
1106
+ matched_topic || find_topic(title)[0]
1107
+ end.compact
1108
+
1047
1109
  end
1048
1110
  end
1049
1111
 
@@ -1065,7 +1127,15 @@ module Howzit
1065
1127
  end
1066
1128
 
1067
1129
  if !topic_matches.empty?
1068
- topic_matches.map! { |topic| topic.is_a?(String) ? find_topic(topic)[0] : topic }
1130
+ topic_matches.map! do |topic|
1131
+ if topic.is_a?(String)
1132
+ matches = find_topic(topic)
1133
+ matches.empty? ? nil : matches[0]
1134
+ elsif topic.is_a?(Howzit::Topic)
1135
+ topic
1136
+ end
1137
+ end
1138
+ topic_matches.compact! # Remove any nil values from failed matches
1069
1139
  topic_matches.each { |topic_match| output.push(process_topic(topic_match, Howzit.options[:run], single: true)) }
1070
1140
  else
1071
1141
  topics.each { |k| output.push(process_topic(k, false, single: false)) }
@@ -1107,11 +1177,11 @@ module Howzit
1107
1177
  # Run each topic with its specific arguments
1108
1178
  topic_specs.each do |topic_match, args|
1109
1179
  # Set arguments if provided, otherwise clear them
1110
- if args && !args.empty?
1111
- Howzit.arguments = args.split(/ *, */).map(&:render_arguments)
1112
- else
1113
- Howzit.arguments = []
1114
- end
1180
+ Howzit.arguments = if args && !args.empty?
1181
+ args.split(/ *, */).map(&:render_arguments)
1182
+ else
1183
+ []
1184
+ end
1115
1185
  output.push(process_topic(topic_match, Howzit.options[:run], single: true))
1116
1186
  end
1117
1187
  finalize_output(output)
data/lib/howzit/colors.rb CHANGED
@@ -281,11 +281,40 @@ module Howzit
281
281
  end
282
282
  end
283
283
 
284
- colors = { w: white, k: black, g: green, l: blue,
285
- y: yellow, c: cyan, m: magenta, r: red,
286
- W: bgwhite, K: bgblack, G: bggreen, L: bgblue,
287
- Y: bgyellow, C: bgcyan, M: bgmagenta, R: bgred,
288
- d: dark, b: bold, u: underline, i: italic, x: reset }
284
+ # Build colors hash from configured_colors, generating escape codes directly
285
+ color_map = configured_colors.to_h
286
+ colors = if coloring?
287
+ {
288
+ w: "\e[#{color_map[:white]}m",
289
+ k: "\e[#{color_map[:black]}m",
290
+ g: "\e[#{color_map[:green]}m",
291
+ l: "\e[#{color_map[:blue]}m",
292
+ y: "\e[#{color_map[:yellow]}m",
293
+ c: "\e[#{color_map[:cyan]}m",
294
+ m: "\e[#{color_map[:magenta]}m",
295
+ r: "\e[#{color_map[:red]}m",
296
+ W: "\e[#{color_map[:bgwhite]}m",
297
+ K: "\e[#{color_map[:bgblack]}m",
298
+ G: "\e[#{color_map[:bggreen]}m",
299
+ L: "\e[#{color_map[:bgblue]}m",
300
+ Y: "\e[#{color_map[:bgyellow]}m",
301
+ C: "\e[#{color_map[:bgcyan]}m",
302
+ M: "\e[#{color_map[:bgmagenta]}m",
303
+ R: "\e[#{color_map[:bgred]}m",
304
+ d: "\e[#{color_map[:dark]}m",
305
+ b: "\e[#{color_map[:bold]}m",
306
+ u: "\e[#{color_map[:underline]}m",
307
+ i: "\e[#{color_map[:italic]}m",
308
+ x: "\e[#{color_map[:reset]}m"
309
+ }
310
+ else
311
+ # When coloring is disabled, return empty strings
312
+ {
313
+ w: '', k: '', g: '', l: '', y: '', c: '', m: '', r: '',
314
+ W: '', K: '', G: '', L: '', Y: '', C: '', M: '', R: '',
315
+ d: '', b: '', u: '', i: '', x: ''
316
+ }
317
+ end
289
318
 
290
319
  result = fmt.empty? ? input : format(fmt, colors)
291
320
  # Unescape braces and dollar signs that were escaped to prevent color code interpretation
@@ -295,24 +324,24 @@ module Howzit
295
324
 
296
325
  # Dynamically generate methods for each color name. Each
297
326
  # resulting method can be called with a string or a block.
298
- configured_colors.each do |c, v|
327
+ Color.configured_colors.each do |c, v|
299
328
  new_method = <<-EOSCRIPT
300
- # Color string as #{c}
301
- def #{c}(string = nil)
302
- result = ''
303
- result << "\e[#{v}m" if Howzit::Color.coloring?
304
- if block_given?
305
- result << yield
306
- elsif string.respond_to?(:to_str)
307
- result << string.to_str
308
- elsif respond_to?(:to_str)
309
- result << to_str
310
- else
311
- return result #only switch on
329
+ # Color string as #{c}
330
+ def #{c}(string = nil)
331
+ result = ''
332
+ result << "\e[#{v}m" if Howzit::Color.coloring?
333
+ if block_given?
334
+ result << yield
335
+ elsif string.respond_to?(:to_str)
336
+ result << string.to_str
337
+ elsif respond_to?(:to_str)
338
+ result << to_str
339
+ else
340
+ return result #only switch on
341
+ end
342
+ result << "\e[0m" if Howzit::Color.coloring?
343
+ result
312
344
  end
313
- result << "\e[0m" if Howzit::Color.coloring?
314
- result
315
- end
316
345
  EOSCRIPT
317
346
 
318
347
  module_eval(new_method)
@@ -383,6 +412,5 @@ module Howzit
383
412
  def attributes
384
413
  ATTRIBUTE_NAMES
385
414
  end
386
- extend self
387
415
  end
388
416
  end
@@ -0,0 +1,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+
5
+ module Howzit
6
+ # Condition Evaluator module
7
+ # Handles evaluation of @if/@unless conditions
8
+ module ConditionEvaluator
9
+ class << self
10
+ ##
11
+ ## Evaluate a condition expression
12
+ ##
13
+ ## @param condition [String] The condition to evaluate
14
+ ## @param context [Hash] Context with metadata, arguments, etc.
15
+ ##
16
+ ## @return [Boolean] Result of condition evaluation
17
+ ##
18
+ def evaluate(condition, context = {})
19
+ condition = condition.strip
20
+
21
+ # Handle negation with 'not' or '!'
22
+ negated = false
23
+ if condition =~ /^(not\s+|!)/
24
+ negated = true
25
+ condition = condition.sub(/^(not\s+|!)/, '').strip
26
+ end
27
+
28
+ result = evaluate_condition(condition, context)
29
+ negated ? !result : result
30
+ end
31
+
32
+ private
33
+
34
+ ##
35
+ ## Evaluate a single condition (without negation)
36
+ ##
37
+ def evaluate_condition(condition, context)
38
+ # Handle special conditions FIRST to avoid false matches with comparison patterns
39
+ # Check file contents before other patterns since it has arguments and operators
40
+ if condition =~ /^file\s+contents\s+(.+?)\s+(\*\*=|\*=|\^=|\$=|==|!=|=~)\s*(.+)$/i
41
+ return evaluate_file_contents(condition, context)
42
+ # Check file/dir/topic exists before other patterns since they have arguments
43
+ elsif condition =~ /^(file\s+exists|dir\s+exists|topic\s+exists)\s+(.+)$/i
44
+ return evaluate_special(condition, context)
45
+ elsif condition =~ /^(git\s+dirty|git\s+clean)$/i
46
+ return evaluate_special(condition, context)
47
+ end
48
+
49
+ # Handle =~ regex comparisons separately (before string == to avoid conflicts)
50
+ if (match = condition.match(%r{^(.+?)\s*=~\s*/(.+)/$}))
51
+ left = match[1].strip
52
+ pattern = match[2].strip
53
+
54
+ left_val = get_value(left, context)
55
+ return false if left_val.nil?
56
+
57
+ !!(left_val.to_s =~ /#{pattern}/)
58
+ # Handle comparisons with ==, !=, >, >=, <, <=
59
+ # Determine if numeric or string comparison based on values
60
+ elsif (match = condition.match(/^(.+?)\s*(==|!=|>=|<=|>|<)\s*(.+)$/))
61
+ left = match[1].strip
62
+ operator = match[2]
63
+ right = match[3].strip
64
+
65
+ left_val = get_value(left, context)
66
+ # If get_value returned nil, try using the original string as a literal
67
+ left_val = left if left_val.nil? && numeric_value(left)
68
+ right_val = get_value(right, context)
69
+ # If get_value returned nil, try using the original string as a literal
70
+ right_val = right if right_val.nil? && numeric_value(right)
71
+
72
+ # Try to convert to numbers
73
+ left_num = numeric_value(left_val)
74
+ right_num = numeric_value(right_val)
75
+
76
+ # If both are numeric, use numeric comparison
77
+ if left_num && right_num
78
+ case operator
79
+ when '=='
80
+ left_num == right_num
81
+ when '!='
82
+ left_num != right_num
83
+ when '>'
84
+ left_num > right_num
85
+ when '>='
86
+ left_num >= right_num
87
+ when '<'
88
+ left_num < right_num
89
+ when '<='
90
+ left_num <= right_num
91
+ else
92
+ false
93
+ end
94
+ # Otherwise use string comparison for == and !=, or return false for others
95
+ else
96
+ case operator
97
+ when '=='
98
+ # Handle nil comparisons
99
+ left_val.nil? == right_val.nil? && (left_val.nil? || left_val.to_s == right_val.to_s)
100
+ when '!='
101
+ left_val.nil? != right_val.nil? || (!left_val.nil? && !right_val.nil? && left_val.to_s != right_val.to_s)
102
+ else
103
+ # For >, >=, <, <=, return false if not numeric
104
+ false
105
+ end
106
+ end
107
+ # Handle string-only comparisons: **= (fuzzy match), *= (contains), ^= (starts with), $= (ends with)
108
+ # Note: **= must come before *= in the regex to avoid matching *= first
109
+ elsif (match = condition.match(/^(.+?)\s*(\*\*=|\*=|\^=|\$=)\s*(.+)$/))
110
+ left = match[1].strip
111
+ operator = match[2]
112
+ right = match[3].strip
113
+
114
+ left_val = get_value(left, context)
115
+ right_val = get_value(right, context)
116
+ # If right side is nil (variable not found), treat it as a literal string
117
+ right_val = right if right_val.nil?
118
+
119
+ return false if left_val.nil? || right_val.nil?
120
+
121
+ case operator
122
+ when '*='
123
+ left_val.to_s.include?(right_val.to_s)
124
+ when '^='
125
+ left_val.to_s.start_with?(right_val.to_s)
126
+ when '$='
127
+ left_val.to_s.end_with?(right_val.to_s)
128
+ when '**='
129
+ # Fuzzy match: split search string into chars and join with .*? for regex
130
+ pattern = "^.*?#{right_val.to_s.split('').map { |c| Regexp.escape(c) }.join('.*?')}.*?$"
131
+ !!(left_val.to_s =~ /#{pattern}/)
132
+ else
133
+ false
134
+ end
135
+ # Simple existence check (just a variable name)
136
+ else
137
+ val = get_value(condition, context)
138
+ !val.nil? && val.to_s != ''
139
+ end
140
+ end
141
+
142
+ ##
143
+ ## Get value from various sources
144
+ ##
145
+ def get_value(expr, context)
146
+ expr = expr.strip
147
+
148
+ # Remove quotes if present
149
+ return Regexp.last_match(1) if expr =~ /^["'](.+)["']$/
150
+
151
+ # Remove ${} wrapper if present (for consistency with variable substitution syntax)
152
+ expr = Regexp.last_match(1) if expr =~ /^\$\{(.+)\}$/
153
+
154
+ # Check positional arguments
155
+ if expr =~ /^\$(\d+)$/
156
+ idx = Regexp.last_match(1).to_i - 1
157
+ return Howzit.arguments[idx] if Howzit.arguments && Howzit.arguments[idx]
158
+ end
159
+
160
+ # Check named arguments
161
+ return Howzit.named_arguments[expr.to_sym] if Howzit.named_arguments&.key?(expr.to_sym)
162
+ return Howzit.named_arguments[expr] if Howzit.named_arguments&.key?(expr)
163
+
164
+ # Check metadata (from context only, to avoid circular dependencies)
165
+ metadata = context[:metadata]
166
+ return metadata[expr] if metadata&.key?(expr)
167
+ return metadata[expr.downcase] if metadata&.key?(expr.downcase)
168
+
169
+ # Check environment variables
170
+ return ENV[expr] if ENV.key?(expr)
171
+ return ENV[expr.upcase] if ENV.key?(expr.upcase)
172
+
173
+ # Check for special values: cwd, working directory
174
+ return Dir.pwd if expr =~ /^(cwd|working\s+directory)$/i
175
+
176
+ # Return nil if nothing matched (variable is undefined)
177
+ nil
178
+ end
179
+
180
+ ##
181
+ ## Convert value to numeric if possible
182
+ ##
183
+ def numeric_value(val)
184
+ return val if val.is_a?(Numeric)
185
+
186
+ str = val.to_s.strip
187
+ return nil if str.empty?
188
+
189
+ # Try integer first
190
+ return str.to_i if str =~ /^-?\d+$/
191
+
192
+ # Try float
193
+ return str.to_f if str =~ /^-?\d+\.\d+$/
194
+
195
+ nil
196
+ end
197
+
198
+ ##
199
+ ## Evaluate special conditions
200
+ ##
201
+ def evaluate_special(condition, context)
202
+ condition = condition.downcase.strip
203
+
204
+ if condition =~ /^git\s+dirty$/i
205
+ git_dirty?
206
+ elsif condition =~ /^git\s+clean$/i
207
+ !git_dirty?
208
+ elsif (match = condition.match(/^file\s+exists\s+(.+)$/i))
209
+ file = match[1].strip
210
+ # get_value returns nil if not found, so use original path if nil
211
+ file_val = get_value(file, context)
212
+ file_path = file_val.nil? ? file : file_val.to_s
213
+ File.exist?(file_path) && !File.directory?(file_path)
214
+ elsif (match = condition.match(/^dir\s+exists\s+(.+)$/i))
215
+ dir = match[1].strip
216
+ dir_val = get_value(dir, context)
217
+ dir_path = dir_val.nil? ? dir : dir_val.to_s
218
+ File.directory?(dir_path)
219
+ elsif (match = condition.match(/^topic\s+exists\s+(.+)$/i))
220
+ topic_name = match[1].strip
221
+ topic_name = get_value(topic_name, context).to_s
222
+ find_topic(topic_name)
223
+ else
224
+ false
225
+ end
226
+ end
227
+
228
+ ##
229
+ ## Check if git repository is dirty
230
+ ##
231
+ def git_dirty?
232
+ return false unless `which git`.strip != ''
233
+
234
+ Dir.chdir(Dir.pwd) do
235
+ `git diff --quiet 2>/dev/null`
236
+ $CHILD_STATUS.exitstatus != 0
237
+ end
238
+ end
239
+
240
+ ##
241
+ ## Check if topic exists in buildnote
242
+ ##
243
+ def find_topic(topic_name)
244
+ return false unless Howzit.buildnote
245
+
246
+ matches = Howzit.buildnote.find_topic(topic_name)
247
+ !matches.empty?
248
+ end
249
+
250
+ ##
251
+ ## Evaluate file contents condition
252
+ ## Reads file and performs string comparison
253
+ ##
254
+ def evaluate_file_contents(condition, context)
255
+ match = condition.match(/^file\s+contents\s+(.+?)\s+(\*\*=|\*=|\^=|\$=|==|!=|=~)\s*(.+)$/i)
256
+ return false unless match
257
+
258
+ file_path = match[1].strip
259
+ operator = match[2]
260
+ search_value = match[3].strip
261
+
262
+ # Resolve file path (could be a variable)
263
+ file_path_val = get_value(file_path, context)
264
+ file_path = file_path_val.nil? ? file_path : file_path_val.to_s
265
+
266
+ # Resolve search value (could be a variable)
267
+ search_val = get_value(search_value, context)
268
+ search_val = search_val.nil? ? search_value : search_val.to_s
269
+
270
+ # Read file contents
271
+ return false unless File.exist?(file_path) && !File.directory?(file_path)
272
+
273
+ begin
274
+ file_contents = File.read(file_path).strip
275
+ rescue StandardError
276
+ return false
277
+ end
278
+
279
+ # Perform comparison based on operator
280
+ case operator
281
+ when '=='
282
+ file_contents == search_val.to_s
283
+ when '!='
284
+ file_contents != search_val.to_s
285
+ when '*='
286
+ file_contents.include?(search_val.to_s)
287
+ when '^='
288
+ file_contents.start_with?(search_val.to_s)
289
+ when '$='
290
+ file_contents.end_with?(search_val.to_s)
291
+ when '**='
292
+ # Fuzzy match: split search string into chars and join with .*? for regex
293
+ pattern = "^.*?#{search_val.to_s.split('').map { |c| Regexp.escape(c) }.join('.*?')}.*?$"
294
+ !!(file_contents =~ /#{pattern}/)
295
+ when '=~'
296
+ # Regex match - search_value should be a regex pattern
297
+ pattern = search_val.to_s
298
+ # Remove leading/trailing slashes if present
299
+ pattern = pattern[1..-2] if pattern.start_with?('/') && pattern.end_with?('/')
300
+ !!(file_contents =~ /#{pattern}/)
301
+ else
302
+ false
303
+ end
304
+ end
305
+ end
306
+ end
307
+ end