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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +71 -0
- data/Rakefile +4 -3
- data/bin/howzit +65 -65
- data/howzit.gemspec +1 -1
- data/lib/howzit/buildnote.rb +115 -45
- data/lib/howzit/colors.rb +50 -22
- data/lib/howzit/condition_evaluator.rb +307 -0
- data/lib/howzit/conditional_content.rb +96 -0
- data/lib/howzit/config.rb +15 -3
- data/lib/howzit/console_logger.rb +14 -2
- data/lib/howzit/prompt.rb +20 -12
- data/lib/howzit/run_report.rb +1 -1
- data/lib/howzit/script_comm.rb +105 -0
- data/lib/howzit/stringutils.rb +40 -6
- data/lib/howzit/task.rb +11 -2
- data/lib/howzit/topic.rb +31 -10
- data/lib/howzit/util.rb +11 -3
- data/lib/howzit/version.rb +1 -1
- data/lib/howzit.rb +4 -1
- data/spec/condition_evaluator_spec.rb +261 -0
- data/spec/conditional_blocks_integration_spec.rb +159 -0
- data/spec/conditional_content_spec.rb +296 -0
- data/spec/script_comm_spec.rb +303 -0
- data/spec/spec_helper.rb +3 -1
- metadata +14 -3
data/lib/howzit/buildnote.rb
CHANGED
|
@@ -290,22 +290,14 @@ module Howzit
|
|
|
290
290
|
|
|
291
291
|
title = File.basename(Dir.pwd)
|
|
292
292
|
# prompt = TTY::Prompt.new
|
|
293
|
-
|
|
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(
|
|
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?).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1025
|
+
elsif Howzit.options[:run]
|
|
968
1026
|
# No arguments
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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!
|
|
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
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|