howzit 2.1.40 → 2.1.42

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c8f040ba3e136aeb10470c40de3cc8272209d176841686aa242dffee1b9fb33f
4
- data.tar.gz: a0e263004a627de3104cb52bb47a04aecacb7eab2389b34819d2ece891331c42
3
+ metadata.gz: 8916b2ac37cd920db8008fe7a9aa28a80b4c5265083b4b257fb8ea1113938efb
4
+ data.tar.gz: 9486d6ca52f82ea5059bffd0de58333b82eedfc623d03f6325817caf8c956d4f
5
5
  SHA512:
6
- metadata.gz: 3d1ce1e58eb28518b0c977f5012436909100cc48fa56dfbf1cedf01389fb02a1ef32823d99b74f065456c07d105f54a1a9dc236a6c73050251ec6180f935af0b
7
- data.tar.gz: e9197120a0dd16ca9aefae85f4dde9f09f06efe56d034faf65eee180aeb5fad02fbe158d8a59b17b007622acfaa44da9aa82312159bc488d49c2f61e5a57568c
6
+ metadata.gz: 5819786618c45538e42f21243102043b1ed408f516b0b946adaa9a2819d0080c2804cba47987f4ba0ed98b21ad7499d42cc825c2f89efb63cc4f6d8013bd743d
7
+ data.tar.gz: 2a90edf5e41602e1c6fc50ab21a6bfc3531b598f3a5cc381492f284bbb588b2590589fbc23da0f007a021605d46f7246e2908eed89895c175c24838b4755b285
data/CHANGELOG.md CHANGED
@@ -1,3 +1,32 @@
1
+ ### 2.1.42
2
+
3
+ 2026-06-29 06:52
4
+
5
+ #### NEW
6
+
7
+ - `includes:` metadata to pull topics from external build note files or project directories (comma-separated paths, optional `[topic | filter]` like templates); directory paths auto-detect the build note; included topics are prefixed by basename; local topics always win on duplicate names.
8
+
9
+ #### FIXED
10
+
11
+ - Paged build note output no longer opens with the first line scrolled off screen (pager receives the full buffer via IO.popen instead of starting on partial pipe input).
12
+
13
+ ### 2.1.41
14
+
15
+ 2026-05-10 08:18
16
+
17
+ #### NEW
18
+
19
+ - `-z name` and `-n name` in `@if`/`@unless` conditions for shell-style empty and non-empty checks on named and positional variables.
20
+
21
+ #### IMPROVED
22
+
23
+ - `Util.show` strips safely when the default external encoding rejects multibyte content (fewer crashes under strict ASCII locale when piping or highlighting).
24
+
25
+ #### FIXED
26
+
27
+ - @set_var and @log_level inside @if/@else/@elsif so only the active branch runs; the previous behavior ran every branch and could overwrite variables set in another branch (for example ONLY_RUN set in @if then replaced by @else).
28
+ - Topic title parameters from arguments after `--` so they no longer shift when another topic is parsed first with `@include(...[args])` (CLI positional snapshot kept separate from mutable Howzit.arguments during load).
29
+
1
30
  ### 2.1.40
2
31
 
3
32
  2026-02-04 07:53
data/bin/howzit CHANGED
@@ -9,6 +9,8 @@ Howzit::Color.coloring = $stdout.isatty
9
9
  parts = Shellwords.shelljoin(ARGV).split(/ -- /)
10
10
  args = parts[0] ? Shellwords.shellsplit(parts[0]) : []
11
11
  Howzit.arguments = parts[1] ? Shellwords.shellsplit(parts[1]) : []
12
+ # Immutable snapshot for topic title (param): binding — gather_tasks/@include mutates Howzit.arguments while parsing earlier topics
13
+ Howzit.cli_topic_positional_args = Howzit.arguments&.dup || []
12
14
  Howzit.named_arguments = {}
13
15
 
14
16
  OptionParser.new do |opts|
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'set'
4
+
3
5
  module Howzit
4
6
  # BuildNote Class
5
7
  class BuildNote
@@ -12,9 +14,11 @@ module Howzit
12
14
  ##
13
15
  ## @param file [String] The path to the build note file
14
16
  ##
15
- def initialize(file: nil, meta: nil)
17
+ def initialize(file: nil, meta: nil, skip_includes: false, included_paths: nil)
16
18
  # Track if an explicit file was provided
17
19
  @explicit_file = file ? File.expand_path(file) : nil
20
+ @skip_includes = skip_includes
21
+ @included_paths = included_paths || Set.new
18
22
 
19
23
  # Set @note_file if an explicit file was provided, before calling note_file getter
20
24
  if file
@@ -653,7 +657,7 @@ module Howzit
653
657
  ## @return [Array] extracted topics
654
658
  ##
655
659
  def read_template(template, file, subtopics = nil)
656
- note = BuildNote.new(file: file, meta: @metadata)
660
+ note = BuildNote.new(file: file, meta: @metadata, skip_includes: true, included_paths: @included_paths)
657
661
 
658
662
  template_topics = subtopics.nil? ? note.topics : extract_subtopics(note, subtopics)
659
663
  template_topics.map do |topic|
@@ -751,6 +755,24 @@ module Howzit
751
755
  files.min
752
756
  end
753
757
 
758
+ ##
759
+ ## Glob a directory for a valid build note filename
760
+ ##
761
+ ## @param dir [String] Directory to search
762
+ ##
763
+ ## @return [String, nil] Absolute path to build note file
764
+ ##
765
+ def glob_note_in(dir)
766
+ pattern = File.join(File.expand_path(dir), '*.{txt,md,markdown}')
767
+ files = Dir.glob(pattern).select { |f| File.basename(f).build_note? }
768
+ return nil if files.empty?
769
+
770
+ priority_files = files.select { |f| File.basename(f) =~ /^(buildnotes|howzit)\./i }
771
+ return priority_files.min unless priority_files.empty?
772
+
773
+ files.min
774
+ end
775
+
754
776
  ##
755
777
  ## Search for a valid build note, checking current
756
778
  ## directory, git top level directory, and parent
@@ -794,26 +816,117 @@ module Howzit
794
816
  end
795
817
 
796
818
  ##
797
- ## Read a list of topics from an included template
819
+ ## Read a list of topics from included templates and includes: metadata
798
820
  ##
799
- ## @param content [String] The template contents
821
+ ## @param content [String] The build note contents
800
822
  ##
801
823
  def get_template_topics(content)
802
824
  leader = content.split(/^#/)[0].strip
803
825
 
804
- template_topics = []
826
+ external_topics = []
805
827
 
806
- return template_topics if leader.empty?
828
+ return external_topics if leader.empty?
807
829
 
808
830
  data = leader.metadata
809
831
 
810
832
  if data.key?('template')
811
833
  templates = data['template'].strip.split(/\s*,\s*/)
812
834
 
813
- template_topics.concat(gather_templates(templates))
835
+ external_topics.concat(gather_templates(templates))
814
836
  end
815
837
 
816
- template_topics
838
+ if data.key?('includes') && !@skip_includes
839
+ includes = data['includes'].strip.split(/\s*,\s*/)
840
+
841
+ external_topics.concat(gather_includes(includes))
842
+ end
843
+
844
+ external_topics
845
+ end
846
+
847
+ ##
848
+ ## Resolve an includes: metadata entry to a label and build note file path
849
+ ##
850
+ ## @param path_spec [String] File path, directory, or ~ path
851
+ ##
852
+ ## @return [Array, nil] [label, absolute_file_path] or nil if not found
853
+ ##
854
+ def resolve_include_path(path_spec)
855
+ path = File.expand_path(path_spec.to_s.strip)
856
+ return nil unless File.exist?(path)
857
+
858
+ if File.directory?(path)
859
+ note_path = glob_note_in(path)
860
+ return nil unless note_path
861
+
862
+ [File.basename(path), File.expand_path(note_path)]
863
+ elsif File.file?(path)
864
+ [File.basename(path, File.extname(path)), path]
865
+ end
866
+ end
867
+
868
+ ##
869
+ ## Read topics from an external build note referenced by includes: metadata
870
+ ##
871
+ def read_include(label, file, subtopics = nil)
872
+ note = BuildNote.new(file: file, meta: @metadata, skip_includes: true, included_paths: @included_paths)
873
+ included_topics = subtopics.nil? ? note.topics : extract_subtopics(note, subtopics)
874
+ included_topics.map do |topic|
875
+ topic.parent = label
876
+ topic.content = topic.content.render_template(@metadata)
877
+ topic.instance_variable_set(:@title, "#{label}:#{topic.title}") unless topic.title.include?(':')
878
+ topic
879
+ end
880
+ end
881
+
882
+ ##
883
+ ## Load topics from includes: metadata paths (comma-separated)
884
+ ##
885
+ def gather_includes(includes)
886
+ included_topics = []
887
+
888
+ includes.each do |spec|
889
+ spec = spec.strip
890
+ next if spec.empty?
891
+
892
+ path_spec, subtopics = detect_subtopics(spec.dup)
893
+ resolved = resolve_include_path(path_spec)
894
+ unless resolved
895
+ Howzit.console.warn "{br}WARNING:{xr} includes: path not found or has no build note: {bw}#{path_spec}{x}".c
896
+ next
897
+ end
898
+
899
+ label, file = resolved
900
+ abs_file = File.expand_path(file)
901
+ main_file = @note_file ? File.expand_path(@note_file) : nil
902
+
903
+ if main_file && abs_file == main_file
904
+ Howzit.console.warn "{br}WARNING:{xr} includes: skipping self-reference: {bw}#{path_spec}{x}".c
905
+ next
906
+ end
907
+
908
+ if @included_paths.include?(abs_file)
909
+ Howzit.console.warn "{br}WARNING:{xr} includes: already loaded, skipping: {bw}#{path_spec}{x}".c
910
+ next
911
+ end
912
+
913
+ @included_paths.add(abs_file)
914
+ included_topics.concat(read_include(label, abs_file, subtopics))
915
+ end
916
+
917
+ included_topics
918
+ end
919
+
920
+ ##
921
+ ## Merge external topics (templates, includes) into local topics; local wins on duplicate names
922
+ ##
923
+ def merge_external_topics(local_topics, external_topics)
924
+ external_topics.each do |topic|
925
+ base = base_topic_name(topic.title)
926
+ exists_in_local = local_topics.any? { |t| base_topic_name(t.title) == base }
927
+ local_topics.push(topic) unless exists_in_local
928
+ end
929
+ local_topics
817
930
  end
818
931
 
819
932
  # Read in the build notes file and output a hash of
@@ -870,13 +983,7 @@ module Howzit
870
983
  topics.push(topic)
871
984
  end
872
985
 
873
- template_topics.each do |topic|
874
- # Check against local topics array, not @topics, to avoid filtering out templates
875
- # when @topics already has topics (e.g., in stack mode)
876
- topic_base = topic.title.sub(/^.+:/, '').strip.downcase
877
- exists_in_local = topics.any? { |t| t.title.sub(/^.+:/, '').strip.downcase == topic_base }
878
- topics.push(topic) unless exists_in_local
879
- end
986
+ merge_external_topics(topics, template_topics)
880
987
 
881
988
  topics
882
989
  end
@@ -35,6 +35,17 @@ module Howzit
35
35
  ## Evaluate a single condition (without negation)
36
36
  ##
37
37
  def evaluate_condition(condition, context)
38
+ # Shell-style empty / non-empty tests (bash -z / -n)
39
+ if (match = condition.match(/^-z\s+(.+)$/i))
40
+ name = match[1].strip
41
+ val = get_value(name, context)
42
+ return val.nil? || val.to_s.strip.empty?
43
+ elsif (match = condition.match(/^-n\s+(.+)$/i))
44
+ name = match[1].strip
45
+ val = get_value(name, context)
46
+ return !val.nil? && !val.to_s.strip.empty?
47
+ end
48
+
38
49
  # Handle special conditions FIRST to avoid false matches with comparison patterns
39
50
  # Check file contents before other patterns since it has arguments and operators
40
51
  if condition =~ /^file\s+contents\s+(.+?)\s+(\*\*=|\*=|\^=|\$=|==|!=|=~)\s*(.+)$/i
data/lib/howzit/topic.rb CHANGED
@@ -25,7 +25,7 @@ module Howzit
25
25
  @named_args = {}
26
26
  @metadata = metadata
27
27
  @source_file = source_file
28
- arguments
28
+ arguments(from_cli_snapshot: true)
29
29
 
30
30
  @directives = parse_directives_with_conditionals
31
31
  @tasks = gather_tasks
@@ -33,10 +33,24 @@ module Howzit
33
33
  end
34
34
 
35
35
  # Get named arguments from title
36
- def arguments
36
+ # from_cli_snapshot: use Howzit.cli_topic_positional_args (argv after `--`) so earlier
37
+ # topics' gather_tasks cannot clobber positional binding. Re-entrant @include [a,b]
38
+ # calls pass false to use live Howzit.arguments.
39
+ def arguments(from_cli_snapshot: false)
37
40
  @arg_definitions = []
38
41
  return unless @title =~ /\(.*?\) *$/
39
42
 
43
+ positional = if from_cli_snapshot
44
+ # Specs / non-CLI: leave unset to keep using Howzit.arguments
45
+ if Howzit.cli_topic_positional_args.nil?
46
+ Howzit.arguments || []
47
+ else
48
+ Howzit.cli_topic_positional_args
49
+ end
50
+ else
51
+ Howzit.arguments || []
52
+ end
53
+
40
54
  a = @title.match(/\((?<args>.*?)\) *$/)
41
55
  args = a['args'].split(/ *, */).each(&:strip)
42
56
 
@@ -45,8 +59,8 @@ module Howzit
45
59
  # Store original definition for display purposes
46
60
  @arg_definitions << (default ? "#{arg_name}:#{default}" : arg_name)
47
61
 
48
- @named_args[arg_name] = if Howzit.arguments && Howzit.arguments.count >= idx + 1
49
- Howzit.arguments[idx]
62
+ @named_args[arg_name] = if positional && positional.count >= idx + 1
63
+ positional[idx]
50
64
  else
51
65
  default
52
66
  end
@@ -642,6 +656,7 @@ module Howzit
642
656
  if line =~ /^@log_level\s*\(([^)]+)\)\s*$/i
643
657
  log_level = Regexp.last_match(1).strip
644
658
  conditional_path = conditional_stack.dup
659
+ conditional_path << current_branch_index if current_branch_index
645
660
  directives << Howzit::Directive.new(
646
661
  type: :log_level,
647
662
  log_level_value: log_level,
@@ -664,6 +679,7 @@ module Howzit
664
679
  # Remove quotes from value if present (handles both single and double quotes)
665
680
  var_value = Regexp.last_match(1) if var_value =~ /^["'](.+)["']$/
666
681
  conditional_path = conditional_stack.dup
682
+ conditional_path << current_branch_index if current_branch_index
667
683
  directives << Howzit::Directive.new(
668
684
  type: :set_var,
669
685
  var_name: var_name,
@@ -818,12 +834,16 @@ module Howzit
818
834
 
819
835
  # Handle @log_level directive (before task check)
820
836
  if directive.log_level?
837
+ next unless directive_in_active_branch?(directive, conditional_state)
838
+
821
839
  current_log_level = directive.log_level_value
822
840
  next
823
841
  end
824
842
 
825
843
  # Handle @set_var directive (before task check)
826
844
  if directive.set_var?
845
+ next unless directive_in_active_branch?(directive, conditional_state)
846
+
827
847
  # Set the variable in named_arguments
828
848
  Howzit.named_arguments ||= {}
829
849
  value = directive.var_value
@@ -856,34 +876,7 @@ module Howzit
856
876
  # Handle task directives
857
877
  next unless directive.task?
858
878
 
859
- # Check if all parent conditionals are true
860
- should_execute = true
861
-
862
- # If path ends with an @elsif/@else, skip the parent @if index
863
- # (the index right before the elsif/else in the path)
864
- path_to_check = directive.conditional_path.dup
865
- if path_to_check.length >= 2
866
- last_idx = path_to_check.last
867
- last_state = conditional_state[last_idx]
868
- if last_state && %w[elsif else].include?(last_state[:directive_type])
869
- # Skip the parent @if index (the one before the elsif/else)
870
- parent_if_idx = path_to_check[path_to_check.length - 2]
871
- parent_if_state = conditional_state[parent_if_idx]
872
- if parent_if_state && %w[if unless].include?(parent_if_state[:directive_type])
873
- path_to_check.delete(parent_if_idx)
874
- end
875
- end
876
- end
877
-
878
- path_to_check.each do |cond_idx|
879
- cond_state = conditional_state[cond_idx]
880
- if cond_state.nil? || !cond_state[:evaluated] || !cond_state[:result]
881
- should_execute = false
882
- break
883
- end
884
- end
885
-
886
- next unless should_execute
879
+ next unless directive_in_active_branch?(directive, conditional_state)
887
880
 
888
881
  # Convert directive to task
889
882
  task = directive.to_task(self, current_log_level: current_log_level)
@@ -939,6 +932,38 @@ module Howzit
939
932
  output
940
933
  end
941
934
 
935
+ ##
936
+ ## Whether a directive nested under @if/@unless/@elsif/@else should run (same rules as tasks).
937
+ ##
938
+ def directive_in_active_branch?(directive, conditional_state)
939
+ path = directive.conditional_path || []
940
+ return true if path.empty?
941
+
942
+ should_execute = true
943
+ path_to_check = path.dup
944
+ if path_to_check.length >= 2
945
+ last_idx = path_to_check.last
946
+ last_state = conditional_state[last_idx]
947
+ if last_state && %w[elsif else].include?(last_state[:directive_type])
948
+ parent_if_idx = path_to_check[-2]
949
+ parent_if_state = conditional_state[parent_if_idx]
950
+ if parent_if_state && %w[if unless].include?(parent_if_state[:directive_type])
951
+ path_to_check.delete(parent_if_idx)
952
+ end
953
+ end
954
+ end
955
+
956
+ path_to_check.each do |cond_idx|
957
+ cond_state = conditional_state[cond_idx]
958
+ if cond_state.nil? || !cond_state[:evaluated] || !cond_state[:result]
959
+ should_execute = false
960
+ break
961
+ end
962
+ end
963
+
964
+ should_execute
965
+ end
966
+
942
967
  ##
943
968
  ## Find the index of the matching @if/@unless for an @elsif/@else/@end
944
969
  ##
data/lib/howzit/util.rb CHANGED
@@ -123,42 +123,37 @@ module Howzit
123
123
  rescue Errno::EPIPE
124
124
  # Pipe closed, ignore
125
125
  end
126
- return
126
+ return true
127
127
  end
128
128
 
129
- read_io, write_io = IO.pipe
130
-
131
- input = $stdin
132
-
133
- pid = Kernel.fork do
134
- write_io.close
135
- input.reopen(read_io)
136
- read_io.close
137
-
138
- # Wait until we have input before we start the pager
139
- IO.select [input]
140
-
141
- pager = which_pager
142
-
129
+ pager = which_pager
130
+ if pager.nil?
143
131
  begin
144
- exec(pager)
145
- rescue SystemCallError => e
146
- Howzit.console.error(e)
147
- exit 1
132
+ puts text
133
+ rescue Errno::EPIPE
134
+ # Pipe closed, ignore
148
135
  end
136
+ return true
149
137
  end
150
138
 
151
- read_io.close
152
- begin
153
- write_io.write(text)
139
+ # Write the full buffer to the pager stdin before closing it. The old
140
+ # fork + IO.select approach started less as soon as the first bytes
141
+ # arrived, which could leave the initial viewport scrolled past line 1.
142
+ IO.popen(pager, 'w') do |io|
143
+ io.write(text)
154
144
  rescue Errno::EPIPE
155
145
  # User quit pager before we finished writing, ignore
156
146
  end
157
- write_io.close
158
147
 
159
- _, status = Process.waitpid2(pid)
148
+ Process.last_status.success?
149
+ end
160
150
 
161
- status.success?
151
+ # Strip safely when external encoding rejects multibyte content (e.g. US-ASCII default).
152
+ def safe_strip(str)
153
+ s = str.to_s
154
+ s.strip
155
+ rescue Encoding::CompatibilityError
156
+ s.encode('UTF-8', invalid: :replace, undef: :replace).strip
162
157
  end
163
158
 
164
159
  # print output to terminal
@@ -180,7 +175,10 @@ module Howzit
180
175
  pipes = "|#{hl}" if hl
181
176
  end
182
177
 
183
- output = `echo #{Shellwords.escape(string.strip)}#{pipes}`.strip
178
+ string = safe_strip(string)
179
+
180
+ raw_output = `echo #{Shellwords.escape(string)}#{pipes}`
181
+ output = safe_strip(raw_output)
184
182
 
185
183
  if options[:paginate] && Howzit.options[:paginate]
186
184
  page(output)
@@ -3,5 +3,5 @@
3
3
  # Primary module for this gem.
4
4
  module Howzit
5
5
  # Current Howzit version.
6
- VERSION = '2.1.40'
6
+ VERSION = '2.1.42'
7
7
  end
data/lib/howzit.rb CHANGED
@@ -60,7 +60,7 @@ require 'tty/box'
60
60
  # Main module for howzit
61
61
  module Howzit
62
62
  class << self
63
- attr_accessor :arguments, :named_arguments, :cli_args, :run_log, :multi_topic_run
63
+ attr_accessor :arguments, :named_arguments, :cli_topic_positional_args, :cli_args, :run_log, :multi_topic_run
64
64
 
65
65
  ##
66
66
  ## Holds a Configuration object with methods and a @settings hash
data/spec/cli_spec.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require 'cli-test'
4
5
 
5
6
  # https://github.com/thoiberg/cli-test
6
7
  describe 'CLI' do
@@ -122,6 +122,30 @@ describe Howzit::ConditionEvaluator do
122
122
  expect(described_class.evaluate('${env} == "production"', {})).to be true
123
123
  expect(described_class.evaluate('${var} == "other"', {})).to be false
124
124
  end
125
+
126
+ it 'evaluates -z (empty) for named arguments' do
127
+ Howzit.named_arguments = { 'option' => '', other: 'x' }
128
+ expect(described_class.evaluate('-z option', {})).to be true
129
+ expect(described_class.evaluate('-z other', {})).to be false
130
+ end
131
+
132
+ it 'evaluates -n (non-empty) for named arguments' do
133
+ Howzit.named_arguments = { 'option' => '', other: 'x' }
134
+ expect(described_class.evaluate('-n option', {})).to be false
135
+ expect(described_class.evaluate('-n other', {})).to be true
136
+ end
137
+
138
+ it 'treats undefined named argument as empty for -z / -n' do
139
+ Howzit.named_arguments = {}
140
+ expect(described_class.evaluate('-z missing', {})).to be true
141
+ expect(described_class.evaluate('-n missing', {})).to be false
142
+ end
143
+
144
+ it 'supports not -z and not -n' do
145
+ Howzit.named_arguments = { opt: 'yes' }
146
+ expect(described_class.evaluate('not -z opt', {})).to be true
147
+ expect(described_class.evaluate('not -n opt', {})).to be false
148
+ end
125
149
  end
126
150
 
127
151
  context 'with metadata' do
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'fileutils'
5
+ require 'tmpdir'
6
+
7
+ describe 'includes: metadata' do
8
+ let(:temp_dir) { File.expand_path(Dir.mktmpdir('howzit_includes_test')) }
9
+ let(:external_dir) { File.expand_path(File.join(temp_dir, 'external_project')) }
10
+ let(:main_dir) { File.expand_path(File.join(temp_dir, 'main_project')) }
11
+
12
+ before do
13
+ FileUtils.mkdir_p(external_dir)
14
+ FileUtils.mkdir_p(main_dir)
15
+
16
+ File.write(
17
+ File.join(external_dir, 'buildnotes.md'),
18
+ <<~NOTE
19
+ # External Project
20
+
21
+ ## Shared Topic
22
+
23
+ @run(echo external) External task
24
+
25
+ ## External Only
26
+
27
+ @run(echo only_external) Only in external
28
+ NOTE
29
+ )
30
+
31
+ File.write(
32
+ File.join(main_dir, 'buildnotes.md'),
33
+ <<~NOTE
34
+ includes: #{external_dir}
35
+
36
+ # Main Project
37
+
38
+ ## Shared Topic
39
+
40
+ @run(echo local) Local task wins
41
+
42
+ ## Main Only
43
+
44
+ @run(echo main) Main task
45
+ NOTE
46
+ )
47
+
48
+ Dir.chdir(main_dir)
49
+ Howzit.instance_variable_set(:@buildnote, nil)
50
+ Howzit.options[:stack] = false
51
+ Howzit.options[:include_upstream] = false
52
+ end
53
+
54
+ after do
55
+ Dir.chdir(Dir.tmpdir)
56
+ FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir)
57
+ Howzit.instance_variable_set(:@buildnote, nil)
58
+ end
59
+
60
+ it 'loads topics from an included project directory' do
61
+ buildnote = Howzit::BuildNote.new
62
+ external = buildnote.find_topic('External Only')
63
+
64
+ expect(external).not_to be_empty
65
+ expect(external[0].title).to eq('external_project:External Only')
66
+ end
67
+
68
+ it 'prefers local topics over included topics with the same name' do
69
+ buildnote = Howzit::BuildNote.new
70
+ matches = buildnote.find_topic('Shared Topic')
71
+
72
+ expect(matches.length).to eq(1)
73
+ expect(matches[0].title).to eq('Shared Topic')
74
+ expect(matches[0].tasks.first.action).to include('local')
75
+ end
76
+
77
+ it 'includes topics from an explicit file path' do
78
+ shared_file = File.join(temp_dir, 'shared.md')
79
+ File.write(
80
+ shared_file,
81
+ <<~NOTE
82
+ # Shared file
83
+
84
+ ## From File
85
+
86
+ @run(echo from_file) From file
87
+ NOTE
88
+ )
89
+
90
+ File.write(
91
+ File.join(main_dir, 'buildnotes.md'),
92
+ <<~NOTE
93
+ includes: #{shared_file}
94
+
95
+ # Main
96
+
97
+ ## Local
98
+ NOTE
99
+ )
100
+
101
+ Howzit.instance_variable_set(:@buildnote, nil)
102
+ buildnote = Howzit::BuildNote.new
103
+ topic = buildnote.find_topic('From File')[0]
104
+
105
+ expect(topic.title).to eq('shared:From File')
106
+ end
107
+
108
+ it 'supports subtopic filters like templates' do
109
+ File.write(
110
+ File.join(main_dir, 'buildnotes.md'),
111
+ <<~NOTE
112
+ includes: #{external_dir}[External Only]
113
+
114
+ # Main
115
+ NOTE
116
+ )
117
+
118
+ Howzit.instance_variable_set(:@buildnote, nil)
119
+ buildnote = Howzit::BuildNote.new
120
+
121
+ expect(buildnote.find_topic('External Only')).not_to be_empty
122
+ expect(buildnote.find_topic('Shared Topic')).to be_empty
123
+ end
124
+ end
@@ -317,4 +317,53 @@ describe 'Sequential Conditional Evaluation' do
317
317
  expect(Howzit.named_arguments['ALL']).to eq('123')
318
318
  end
319
319
  end
320
+
321
+ describe '@set_var under @if/@else' do
322
+ let(:branch_note) do
323
+ <<~EONOTE
324
+ # Test
325
+
326
+ ## Branch Topic (flag)
327
+
328
+ @if -n flag
329
+ @set_var(RESULT, "yes")
330
+ @else
331
+ @set_var(RESULT, "no")
332
+ @end
333
+
334
+ ```run Echo
335
+ #!/bin/bash
336
+ echo ok
337
+ ```
338
+ EONOTE
339
+ end
340
+
341
+ it 'only runs @set_var from the active branch when @if is true' do
342
+ File.open('builda.md', 'w') { |f| f.puts branch_note }
343
+ Howzit.arguments = ['present']
344
+ Howzit.cli_topic_positional_args = ['present']
345
+ Howzit.named_arguments = {}
346
+ Howzit.instance_variable_set(:@buildnote, nil)
347
+ topic = Howzit.buildnote('builda.md').find_topic('Branch Topic')[0]
348
+ allow(Howzit::Prompt).to receive(:yn).and_return(true)
349
+
350
+ topic.run
351
+
352
+ expect(Howzit.named_arguments['RESULT']).to eq('yes')
353
+ end
354
+
355
+ it 'only runs @set_var from the active branch when @else is taken' do
356
+ File.open('builda.md', 'w') { |f| f.puts branch_note }
357
+ Howzit.arguments = []
358
+ Howzit.cli_topic_positional_args = []
359
+ Howzit.named_arguments = {}
360
+ Howzit.instance_variable_set(:@buildnote, nil)
361
+ topic = Howzit.buildnote('builda.md').find_topic('Branch Topic')[0]
362
+ allow(Howzit::Prompt).to receive(:yn).and_return(true)
363
+
364
+ topic.run
365
+
366
+ expect(Howzit.named_arguments['RESULT']).to eq('no')
367
+ end
368
+ end
320
369
  end
data/spec/spec_helper.rb CHANGED
@@ -8,7 +8,6 @@ unless ENV['CI'] == 'true'
8
8
  end
9
9
 
10
10
  require 'howzit'
11
- require 'cli-test'
12
11
 
13
12
  RSpec.configure do |c|
14
13
  c.expect_with(:rspec) { |e| e.syntax = :expect }
@@ -18,11 +17,19 @@ RSpec.configure do |c|
18
17
  save_buildnote
19
18
  # Reset buildnote cache to ensure fresh instance with updated file
20
19
  Howzit.instance_variable_set(:@buildnote, nil)
20
+ Howzit.arguments = []
21
+ Howzit.cli_topic_positional_args = nil
22
+ Howzit.named_arguments = {}
21
23
  Howzit.options[:include_upstream] = false
22
24
  Howzit.options[:stack] = false
23
25
  Howzit.options[:default] = true
24
26
  Howzit.options[:matching] = 'partial'
25
27
  Howzit.options[:multiple_matches] = 'choose'
28
+ # Point singleton at test fixture (repo may also contain buildnotes.md, etc.)
29
+ Howzit.instance_variable_set(
30
+ :@buildnote,
31
+ Howzit::BuildNote.new(file: File.expand_path('builda.md'))
32
+ )
26
33
  @hz = Howzit.buildnote
27
34
  end
28
35
 
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tempfile'
5
+
6
+ describe 'Topic title positional args vs gather_tasks' do
7
+ it 'uses CLI positional snapshot so a prior topic @include […] does not shift later topic params' do
8
+ Howzit.arguments = ['from_cli']
9
+ Howzit.cli_topic_positional_args = ['from_cli']
10
+
11
+ Tempfile.create(['howzit-pos', '.md']) do |f|
12
+ f.write(<<~MD)
13
+ defined: snap
14
+
15
+ # Note
16
+
17
+ ## First Topic
18
+
19
+ @include(DoesNotNeedToExist[y])
20
+
21
+ ## Widget Topic (only:falafel)
22
+
23
+ ok
24
+ MD
25
+ f.flush
26
+
27
+ note = Howzit::BuildNote.new(file: f.path)
28
+ widget = note.topics.find { |t| t.title == 'Widget Topic' }
29
+ expect(widget).not_to be_nil
30
+ expect(widget.named_args['only']).to eq('from_cli')
31
+ end
32
+ end
33
+ end
data/spec/topic_spec.rb CHANGED
@@ -24,7 +24,7 @@ end
24
24
 
25
25
  describe Howzit::Topic do
26
26
  subject(:topic) do
27
- bn = Howzit.buildnote
27
+ bn = Howzit.buildnote('builda.md')
28
28
  bn.find_topic('Topic Balogna')[0]
29
29
  end
30
30
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: howzit
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.40
4
+ version: 2.1.42
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brett Terpstra
@@ -320,6 +320,7 @@ files:
320
320
  - spec/condition_evaluator_spec.rb
321
321
  - spec/conditional_blocks_integration_spec.rb
322
322
  - spec/conditional_content_spec.rb
323
+ - spec/includes_metadata_spec.rb
323
324
  - spec/log_level_spec.rb
324
325
  - spec/prompt_spec.rb
325
326
  - spec/ruby_gem_spec.rb
@@ -331,6 +332,7 @@ files:
331
332
  - spec/stack_mode_spec.rb
332
333
  - spec/stringutils_spec.rb
333
334
  - spec/task_spec.rb
335
+ - spec/topic_positional_snapshot_spec.rb
334
336
  - spec/topic_spec.rb
335
337
  - spec/util_spec.rb
336
338
  - src/_README.md
@@ -354,7 +356,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
354
356
  - !ruby/object:Gem::Version
355
357
  version: '0'
356
358
  requirements: []
357
- rubygems_version: 4.0.3
359
+ rubygems_version: 3.6.7
358
360
  specification_version: 4
359
361
  summary: Provides a way to access Markdown project notes by topic with query capabilities
360
362
  and the ability to execute the tasks it describes.
@@ -364,6 +366,7 @@ test_files:
364
366
  - spec/condition_evaluator_spec.rb
365
367
  - spec/conditional_blocks_integration_spec.rb
366
368
  - spec/conditional_content_spec.rb
369
+ - spec/includes_metadata_spec.rb
367
370
  - spec/log_level_spec.rb
368
371
  - spec/prompt_spec.rb
369
372
  - spec/ruby_gem_spec.rb
@@ -375,5 +378,6 @@ test_files:
375
378
  - spec/stack_mode_spec.rb
376
379
  - spec/stringutils_spec.rb
377
380
  - spec/task_spec.rb
381
+ - spec/topic_positional_snapshot_spec.rb
378
382
  - spec/topic_spec.rb
379
383
  - spec/util_spec.rb