howzit 2.1.42 → 2.1.45

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: 8916b2ac37cd920db8008fe7a9aa28a80b4c5265083b4b257fb8ea1113938efb
4
- data.tar.gz: 9486d6ca52f82ea5059bffd0de58333b82eedfc623d03f6325817caf8c956d4f
3
+ metadata.gz: 3b5d2120c8484147d2d3cb8f8844073ca027e7a54655385749ee721f645d05a5
4
+ data.tar.gz: 3cef9cda18bf1b43b28d9288e9eda81d30b07fc9ac2505dd932e508240abf86b
5
5
  SHA512:
6
- metadata.gz: 5819786618c45538e42f21243102043b1ed408f516b0b946adaa9a2819d0080c2804cba47987f4ba0ed98b21ad7499d42cc825c2f89efb63cc4f6d8013bd743d
7
- data.tar.gz: 2a90edf5e41602e1c6fc50ab21a6bfc3531b598f3a5cc381492f284bbb588b2590589fbc23da0f007a021605d46f7246e2908eed89895c175c24838b4755b285
6
+ metadata.gz: 4dc4af7fce8a375a33a3dfee3ed2e6f9d914ea3069601aa3426ae552f4c5488d5aafa332522c4eef0a6b4318bd63afef20eda2d638e9c6fd09ce9784c5e6a434
7
+ data.tar.gz: fe60529771944221e4f7acd4bd9883669782f413c5a367ae3c03c8c7d8ce0a0e0413fcf4e086e37584868c58bbea10bb4e24ab49c28583521d901166404146be
data/CHANGELOG.md CHANGED
@@ -1,3 +1,78 @@
1
+ ### 2.1.45
2
+
3
+ 2026-09-27 12:16
4
+
5
+ #### CHANGED
6
+
7
+ - **Multi-topic runs** (comma-separated topics or `default:` metadata) now stop after the first topic with a failed task and mark the remaining topics' tasks as skipped; use --force to run every topic.
8
+
9
+ #### NEW
10
+
11
+ - **Skipped tasks** in the run report: tasks that did not run because of an earlier failure are listed with a skipped marker and "(skipped)" label.
12
+ - **Skipped @include tasks** are expanded to the included topic's individual tasks in the run report.
13
+
14
+ #### IMPROVED
15
+
16
+ - **Conditional topics** list remaining unconditional tasks as skipped after a failure; tasks inside unevaluated conditionals are omitted.
17
+
18
+ #### FIXED
19
+
20
+ - **Run report** now includes the task that failed and stopped a run, instead of omitting it and showing only the earlier successes.
21
+ - **@include** directives now fail when a task in the included topic fails, so the including topic stops instead of continuing with its remaining tasks.
22
+ - **Task totals for @include** reflect the included topic's actual successes and errors instead of counting every included task as a single result.
23
+
24
+ ### 2.1.44
25
+
26
+ 2026-09-27 11:38
27
+
28
+ #### CHANGED
29
+
30
+ - **Task failures** in a parent or subtopic stop the remaining subtopics from running unless --force is used.
31
+ - **@after blocks** on a parent are shown after the parent and all of its subtopics finish.
32
+
33
+ #### NEW
34
+
35
+ - **Subtopics**: deeper headers (### under ##, #### under ###, etc.) are now nested under the preceding shallower topic.
36
+ - **Displaying a parent topic** shows all of its subtopics after it, with subtopic headers drawn in a lighter style (cyan title, dotted rule).
37
+ - **Running a parent topic** runs its own tasks, then every subtopic's tasks in order (depth-first), with a single combined "Ran N tasks" summary.
38
+
39
+ #### IMPROVED
40
+
41
+ - **Showing all topics** (howzit with no arguments, or howzit -r with no default) only outputs top-level topics so subtopics are not repeated.
42
+ - **Selecting both a parent and its subtopic** outputs/runs the subtopic only once, as part of the parent.
43
+ - **Topic lists** (howzit -l and the runnable topics list) indent subtopics under their parents.
44
+ - **@include(Topic)** on a parent runs the parent and all of its subtopics, and task counts in include notes and menus now count subtopic tasks.
45
+
46
+ #### FIXED
47
+
48
+ - **Parent topics with no tasks of their own** no longer warn "No @directive found" when their subtopics contain tasks.
49
+
50
+ ### 2.1.43
51
+
52
+ 2026-09-27 11:35
53
+
54
+ #### CHANGED
55
+
56
+ - **Task failures** in a parent or subtopic stop the remaining subtopics from running unless --force is used.
57
+ - **@after blocks** on a parent are shown after the parent and all of its subtopics finish.
58
+
59
+ #### NEW
60
+
61
+ - **Subtopics**: deeper headers (### under ##, #### under ###, etc.) are now nested under the preceding shallower topic.
62
+ - **Displaying a parent topic** shows all of its subtopics after it, with subtopic headers drawn in a lighter style (cyan title, dotted rule).
63
+ - **Running a parent topic** runs its own tasks, then every subtopic's tasks in order (depth-first), with a single combined "Ran N tasks" summary.
64
+
65
+ #### IMPROVED
66
+
67
+ - **Showing all topics** (howzit with no arguments, or howzit -r with no default) only outputs top-level topics so subtopics are not repeated.
68
+ - **Selecting both a parent and its subtopic** outputs/runs the subtopic only once, as part of the parent.
69
+ - **Topic lists** (howzit -l and the runnable topics list) indent subtopics under their parents.
70
+ - **@include(Topic)** on a parent runs the parent and all of its subtopics, and task counts in include notes and menus now count subtopic tasks.
71
+
72
+ #### FIXED
73
+
74
+ - **Parent topics with no tasks of their own** no longer warn "No @directive found" when their subtopics contain tasks.
75
+
1
76
  ### 2.1.42
2
77
 
3
78
  2026-06-29 06:52
data/Rakefile CHANGED
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rspec, rubocop plugins, and the .rubocop_todo.yml cops depend on the locked bundle
4
+ exec('bundle', 'exec', 'rake', *ARGV) unless ENV['BUNDLE_GEMFILE']
5
+
3
6
  # require 'bump/tasks'
4
7
  require 'English'
5
8
  require 'bundler/gem_tasks'
data/howzit.gemspec CHANGED
@@ -25,7 +25,7 @@ Gem::Specification.new do |spec|
25
25
 
26
26
  spec.required_ruby_version = '>= 2.6.0'
27
27
 
28
- spec.add_development_dependency 'bundler', '~> 2.2'
28
+ spec.add_development_dependency 'bundler', '>= 2.2'
29
29
  spec.add_development_dependency 'rake', '~> 13.0'
30
30
 
31
31
  spec.add_development_dependency 'guard', '~> 2.11'
@@ -169,11 +169,37 @@ module Howzit
169
169
  output = []
170
170
  output.push("{bg}Topics:{x}\n".c)
171
171
  @topics.each do |topic|
172
- output.push("- {bw}#{topic.title}{x}".c)
172
+ output.push("#{subtopic_indent(topic)}- {bw}#{topic.title}{x}".c)
173
173
  end
174
174
  output.join("\n")
175
175
  end
176
176
 
177
+ ##
178
+ ## Indentation for a topic in list output, based on
179
+ ## how many of its parent topics are in the list
180
+ ##
181
+ ## @param topic [Topic] The topic
182
+ ##
183
+ ## @return [String] leading whitespace
184
+ ##
185
+ def subtopic_indent(topic)
186
+ ' ' * topic.ancestors.count { |parent| @topics.any? { |t| t.equal?(parent) } }
187
+ end
188
+
189
+ ##
190
+ ## Topics that aren't nested under another topic in
191
+ ## the build note (subtopics are output with their parent)
192
+ ##
193
+ ## @param topics [Array] Topics to filter, defaults to all topics
194
+ ##
195
+ ## @return [Array] array of Topics
196
+ ##
197
+ def top_level_topics(topics = @topics)
198
+ topics.reject do |topic|
199
+ topic.ancestors.any? { |parent| topics.any? { |t| t.equal?(parent) } }
200
+ end
201
+ end
202
+
177
203
  ##
178
204
  ## Return an array of topic titles
179
205
  ##
@@ -204,7 +230,7 @@ module Howzit
204
230
  def list_runnable_completions
205
231
  output = []
206
232
  @topics.each do |topic|
207
- next unless topic.tasks.count.positive?
233
+ next unless topic.all_tasks.any?
208
234
 
209
235
  output.push(topic.title)
210
236
  end
@@ -222,11 +248,10 @@ module Howzit
222
248
  output.push(%({bg}"Runnable" Topics:{x}\n).c)
223
249
 
224
250
  find_topic(Howzit.options[:for_topic]).each do |topic|
225
- s_out = []
226
-
227
- topic.tasks.each { |task| s_out.push(task.to_list) }
251
+ next if topic.all_tasks.empty?
228
252
 
229
- next if s_out.empty?
253
+ indent = subtopic_indent(topic)
254
+ s_out = topic.tasks.map { |task| "#{indent}#{task.to_list}" }
230
255
 
231
256
  title = topic.title
232
257
  # Show argument definitions with colorized formatting
@@ -235,8 +260,8 @@ module Howzit
235
260
  title += " {l}({x}#{formatted_args}{l}){x}".c
236
261
  end
237
262
 
238
- output.push("- {g}#{title}{x}".c)
239
- output.push(s_out.join("\n"))
263
+ output.push("#{indent}- {g}#{title}{x}".c)
264
+ output.push(s_out.join("\n")) unless s_out.empty?
240
265
  end
241
266
 
242
267
  output.join("\n")
@@ -956,11 +981,13 @@ module Howzit
956
981
 
957
982
  template_topics = get_template_topics(help)
958
983
 
959
- split = help.split(/^##+/)
960
- split.slice!(0)
961
- split.each do |sect|
962
- next if sect.strip.empty?
984
+ sections = help.split(/^(##+)/)
985
+ sections.shift
986
+ parent_stack = []
987
+ sections.each_slice(2) do |marks, sect|
988
+ next if sect.nil? || sect.strip.empty?
963
989
 
990
+ level = marks.length
964
991
  lines = sect.split(/\n/)
965
992
  title = lines.slice!(0).strip
966
993
  prefix = ''
@@ -978,7 +1005,12 @@ module Howzit
978
1005
  # Ensure source_file is always an absolute path
979
1006
  source_file = path || note_file
980
1007
  source_file = File.expand_path(source_file) if source_file
981
- topic = Topic.new(title, prefix + lines.join("\n").strip.render_template(@metadata), @metadata, source_file: source_file)
1008
+ topic = Topic.new(title, prefix + lines.join("\n").strip.render_template(@metadata), @metadata,
1009
+ source_file: source_file, level: level)
1010
+
1011
+ parent_stack.pop while parent_stack.any? && parent_stack.last.level >= level
1012
+ parent_stack.last&.add_subtopic(topic)
1013
+ parent_stack.push(topic)
982
1014
 
983
1015
  topics.push(topic)
984
1016
  end
@@ -1373,13 +1405,13 @@ module Howzit
1373
1405
  process_default_metadata(output)
1374
1406
  else
1375
1407
  Howzit.run_log = []
1376
- Howzit.multi_topic_run = topics.length > 1
1377
- topics.each { |k| output.push(process_topic(k, false, single: false)) }
1408
+ Howzit.multi_topic_run = top_level_topics.length > 1
1409
+ top_level_topics.each { |k| output.push(process_topic(k, false, single: false)) }
1378
1410
  finalize_output(output)
1379
1411
  end
1380
1412
  else
1381
1413
  # Show all topics
1382
- topics.each { |k| output.push(process_topic(k, false, single: false)) }
1414
+ top_level_topics.each { |k| output.push(process_topic(k, false, single: false)) }
1383
1415
  finalize_output(output)
1384
1416
  end
1385
1417
  end
@@ -1525,9 +1557,14 @@ module Howzit
1525
1557
  end
1526
1558
  end
1527
1559
  topic_matches.compact! # Remove any nil values from failed matches
1528
- topic_matches.each { |topic_match| output.push(process_topic(topic_match, Howzit.options[:run], single: true)) }
1560
+ # A matched subtopic is already output/run with its matched parent
1561
+ selected = top_level_topics(topic_matches)
1562
+ selected.each_with_index do |topic_match, idx|
1563
+ output.push(process_topic(topic_match, Howzit.options[:run], single: true))
1564
+ break if stop_after_failure?(selected[(idx + 1)..])
1565
+ end
1529
1566
  else
1530
- topics.each { |k| output.push(process_topic(k, false, single: false)) }
1567
+ top_level_topics.each { |k| output.push(process_topic(k, false, single: false)) }
1531
1568
  end
1532
1569
 
1533
1570
  finalize_output(output)
@@ -1564,7 +1601,7 @@ module Howzit
1564
1601
  end
1565
1602
 
1566
1603
  # Run each topic with its specific arguments
1567
- topic_specs.each do |topic_match, args|
1604
+ topic_specs.each_with_index do |(topic_match, args), idx|
1568
1605
  # Set arguments if provided, otherwise clear them
1569
1606
  Howzit.arguments = if args && !args.empty?
1570
1607
  args.split(/ *, */).map(&:render_arguments)
@@ -1572,10 +1609,29 @@ module Howzit
1572
1609
  []
1573
1610
  end
1574
1611
  output.push(process_topic(topic_match, Howzit.options[:run], single: true))
1612
+ break if stop_after_failure?(topic_specs[(idx + 1)..].map(&:first))
1575
1613
  end
1576
1614
  finalize_output(output)
1577
1615
  end
1578
1616
 
1617
+ ##
1618
+ ## After running a topic in a multi-topic run, check whether a task
1619
+ ## failed. If so (and --force isn't set), log the remaining topics'
1620
+ ## tasks as skipped.
1621
+ ##
1622
+ ## @param remaining [Array] Topics that haven't run yet
1623
+ ##
1624
+ ## @return [Boolean] true if the run should stop
1625
+ ##
1626
+ def stop_after_failure?(remaining)
1627
+ return false unless Howzit.options[:run]
1628
+ return false if Howzit.options[:force]
1629
+ return false unless Howzit.run_log.any? { |entry| !entry[:success] && !entry[:skipped] }
1630
+
1631
+ remaining.each { |topic| topic.log_skipped_tasks(topic.all_tasks) }
1632
+ true
1633
+ end
1634
+
1579
1635
  ##
1580
1636
  ## Parse default metadata value into individual topic specifications
1581
1637
  ##
@@ -26,8 +26,20 @@ module Howzit
26
26
  output_lines.join("\n")
27
27
  end
28
28
 
29
+ def status_symbol(entry)
30
+ return '⏭️' if entry[:skipped]
31
+
32
+ entry[:success] ? '✅' : '❌'
33
+ end
34
+
35
+ def status_reason(entry)
36
+ return 'skipped' if entry[:skipped]
37
+
38
+ entry[:exit_status] ? "exit code #{entry[:exit_status]}" : 'failed'
39
+ end
40
+
29
41
  def format_line(entry, prefix_topic)
30
- symbol = entry[:success] ? '✅' : '❌'
42
+ symbol = status_symbol(entry)
31
43
  parts = ["#{symbol} "]
32
44
  if prefix_topic && entry[:topic] && !entry[:topic].empty?
33
45
  # Escape braces and dollar signs in topic name to prevent color code interpretation
@@ -36,10 +48,11 @@ module Howzit
36
48
  end
37
49
  # Escape braces and dollar signs in task name to prevent color code interpretation
38
50
  task_escaped = entry[:task].gsub(/\{/, '\\{').gsub(/\}/, '\\}').gsub(/\$/, '\\$')
39
- parts << "{by}#{task_escaped} {x}"
40
- unless entry[:success]
41
- reason = entry[:exit_status] ? "exit code #{entry[:exit_status]}" : 'failed'
42
- parts << " {br}(#{reason}){x}"
51
+ if entry[:skipped]
52
+ parts << "{d}#{task_escaped} (skipped){x}"
53
+ else
54
+ parts << "{by}#{task_escaped} {x}"
55
+ parts << " {br}(#{status_reason(entry)}){x}" unless entry[:success]
43
56
  end
44
57
  parts.join.c
45
58
  end
@@ -76,7 +89,7 @@ module Howzit
76
89
  def format_row(entry, prefix_topic)
77
90
  # Use plain emoji without color codes - the emoji itself provides visual meaning
78
91
  # and complex ANSI codes interfere with mdless table rendering
79
- symbol = entry[:success] ? '✅' : '❌'
92
+ symbol = status_symbol(entry)
80
93
 
81
94
  task_parts = []
82
95
  task_parts_plain = []
@@ -94,7 +107,7 @@ module Howzit
94
107
  task_parts_plain << entry[:task]
95
108
 
96
109
  unless entry[:success]
97
- reason = entry[:exit_status] ? "exit code #{entry[:exit_status]}" : 'failed'
110
+ reason = status_reason(entry)
98
111
  task_parts << " {br}(#{reason}){x}"
99
112
  task_parts_plain << " (#{reason})"
100
113
  end
data/lib/howzit/task.rb CHANGED
@@ -5,7 +5,8 @@ require 'English'
5
5
  module Howzit
6
6
  # Task object
7
7
  class Task
8
- attr_reader :type, :title, :action, :arguments, :parent, :optional, :default, :last_status, :log_level, :source_file
8
+ attr_reader :type, :title, :action, :arguments, :parent, :optional, :default, :last_status, :log_level, :source_file,
9
+ :include_results
9
10
 
10
11
  ##
11
12
  ## Initialize a Task object
@@ -141,7 +142,7 @@ module Howzit
141
142
  ##
142
143
  ## Execute an include task
143
144
  ##
144
- ## @return [Array] [[Array] output, [Integer] number of tasks executed]
145
+ ## @return [Array] [[Array] output, [Integer] number of tasks executed, [Boolean] all tasks succeeded]
145
146
  ##
146
147
  def run_include
147
148
  output = []
@@ -150,11 +151,13 @@ module Howzit
150
151
  matches = Howzit.buildnote.find_topic(action)
151
152
  raise "Topic not found: #{action}" if matches.empty?
152
153
 
153
- Howzit.console.info("#{@prefix}{by}Running tasks from {bw}#{matches[0].title}{x}".c)
154
- output.concat(matches[0].run(nested: true))
155
- Howzit.console.info("{by}End include: #{matches[0].tasks.count} tasks{x}".c)
154
+ topic = matches[0]
155
+ Howzit.console.info("#{@prefix}{by}Running tasks from {bw}#{topic.title}{x}".c)
156
+ output.concat(topic.run(nested: true))
157
+ Howzit.console.info("{by}End include: #{topic.all_tasks.count} tasks{x}".c)
156
158
  @last_status = nil
157
- [output, matches[0].tasks.count]
159
+ @include_results = topic.results.slice(:total, :success, :errors)
160
+ [output, @include_results[:total], @include_results[:errors].zero?]
158
161
  end
159
162
 
160
163
  ##
@@ -287,7 +290,8 @@ module Howzit
287
290
  else
288
291
  case @type
289
292
  when :include
290
- output, tasks = run_include
293
+ output, tasks, include_success = run_include
294
+ include_success
291
295
  when :run
292
296
  run_run
293
297
  when :copy
data/lib/howzit/topic.rb CHANGED
@@ -5,9 +5,10 @@ module Howzit
5
5
  class Topic
6
6
  attr_writer :parent
7
7
 
8
- attr_accessor :content
8
+ attr_accessor :content, :parent_topic
9
9
 
10
- attr_reader :title, :tasks, :prereqs, :postreqs, :results, :named_args, :directives, :arg_definitions, :source_file
10
+ attr_reader :title, :tasks, :prereqs, :postreqs, :results, :named_args, :directives, :arg_definitions, :source_file,
11
+ :level, :subtopics
11
12
 
12
13
  ##
13
14
  ## Initialize a topic object
@@ -16,11 +17,15 @@ module Howzit
16
17
  ## @param content [String] The raw topic content
17
18
  ## @param metadata [Hash] Optional metadata hash
18
19
  ## @param source_file [String] Optional path to the build note file this topic came from
20
+ ## @param level [Integer] Markdown header level (2 for ##, 3 for ###, etc.)
19
21
  ##
20
- def initialize(title, content, metadata = nil, source_file: nil)
22
+ def initialize(title, content, metadata = nil, source_file: nil, level: 2)
21
23
  @title = title
22
24
  @content = content
23
25
  @parent = nil
26
+ @parent_topic = nil
27
+ @subtopics = []
28
+ @level = level
24
29
  @nest_level = 0
25
30
  @named_args = {}
26
31
  @metadata = metadata
@@ -32,6 +37,40 @@ module Howzit
32
37
  @results = { total: 0, success: 0, errors: 0, message: ''.c }
33
38
  end
34
39
 
40
+ ##
41
+ ## Nest a topic under this one (a deeper header following this topic)
42
+ ##
43
+ ## @param topic [Topic] The subtopic
44
+ ##
45
+ def add_subtopic(topic)
46
+ topic.parent_topic = self
47
+ @subtopics << topic
48
+ end
49
+
50
+ ##
51
+ ## Tasks from this topic and all nested subtopics, in run order
52
+ ##
53
+ ## @return [Array] Array of Task objects
54
+ ##
55
+ def all_tasks
56
+ @tasks + @subtopics.flat_map(&:all_tasks)
57
+ end
58
+
59
+ ##
60
+ ## Parent topics from nearest to outermost
61
+ ##
62
+ ## @return [Array] Array of Topic objects
63
+ ##
64
+ def ancestors
65
+ list = []
66
+ node = @parent_topic
67
+ while node
68
+ list << node
69
+ node = node.parent_topic
70
+ end
71
+ list
72
+ end
73
+
35
74
  # Get named arguments from title
36
75
  # from_cli_snapshot: use Howzit.cli_topic_positional_args (argv after `--`) so earlier
37
76
  # topics' gather_tasks cannot clobber positional binding. Re-entrant @include [a,b]
@@ -80,7 +119,7 @@ module Howzit
80
119
 
81
120
  def ask_task(task)
82
121
  note = if task.type == :include
83
- task_count = Howzit.buildnote.find_topic(task.action)[0].tasks.count
122
+ task_count = Howzit.buildnote.find_topic(task.action)[0].all_tasks.count
84
123
  " (#{task_count} tasks)"
85
124
  else
86
125
  ''
@@ -95,80 +134,172 @@ module Howzit
95
134
  60
96
135
  end
97
136
 
98
- # Handle run command, execute directives in topic
99
- def run(nested: false)
137
+ ##
138
+ ## Handle run command, execute directives in topic, then in each subtopic
139
+ ##
140
+ ## @param nested [Boolean] Suppress the summary message (topic is being included)
141
+ ## @param as_subtopic [Boolean] Topic is being run as part of a parent topic
142
+ ##
143
+ ## @return [Array] output lines
144
+ ##
145
+ def run(nested: false, as_subtopic: false)
146
+ @results = { total: 0, success: 0, errors: 0, message: ''.c }
100
147
  output = []
101
-
102
148
  cols = check_cols
103
149
 
104
- # Use sequential processing if we have directives with conditionals
105
- if @directives && @directives.any?(&:conditional?)
106
- return run_sequential(nested: nested, output: output, cols: cols)
150
+ confirm_prereqs(cols) if !@prereqs.empty? && (all_tasks.any? || sequential?)
151
+
152
+ if sequential?
153
+ run_sequential(output: output)
154
+ elsif @tasks.any?
155
+ run_tasks(output)
156
+ elsif all_tasks.empty? && !as_subtopic && !directives?
157
+ Howzit.console.warn "{r}--run: No {br}@directive{xr} found in {bw}#{@title}{x}".c
107
158
  end
108
159
 
109
- # Fall back to old behavior for backward compatibility
110
- # Note: @set_var directives are already processed in gather_tasks for non-sequential path
111
- # This section is kept for backward compatibility but shouldn't be needed
160
+ if halted?
161
+ log_skipped_tasks(@subtopics.flat_map(&:all_tasks))
162
+ else
163
+ run_subtopics(output)
164
+ end
112
165
 
113
- if @tasks.count.positive?
114
- unless @prereqs.empty?
115
- begin
116
- puts TTY::Box.frame("{by}#{@prereqs.join("\n\n").wrap(cols - 4)}{x}".c, width: cols)
117
- rescue Errno::EPIPE
118
- # Pipe closed, ignore
119
- end
120
- res = Prompt.yn('Have the above prerequisites been met?', default: true)
121
- Process.exit 1 unless res
166
+ if @results[:total].positive? || (all_tasks.any? && !sequential?)
167
+ @results[:message] += results_message
168
+ output.push(@results[:message]) if Howzit.options[:log_level] < 2 && !nested && !Howzit.options[:run]
169
+ end
122
170
 
123
- end
171
+ show_postreqs(cols)
124
172
 
125
- @tasks.each do |task|
126
- next if (task.optional || Howzit.options[:ask]) && !ask_task(task)
173
+ output
174
+ end
127
175
 
128
- run_output, total, success = task.run
176
+ ##
177
+ ## Whether the topic contains conditional directives and must be run sequentially
178
+ ##
179
+ def sequential?
180
+ @directives&.any?(&:conditional?) || false
181
+ end
129
182
 
130
- output.concat(run_output)
131
- @results[:total] += total
183
+ ##
184
+ ## Whether this topic or any subtopic contains directives,
185
+ ## including non-task directives like @set_var and @log_level
186
+ ##
187
+ def directives?
188
+ @directives&.any? || @subtopics.any?(&:directives?)
189
+ end
132
190
 
133
- if success
134
- @results[:success] += total
135
- else
136
- Howzit.console.warn %({bw}\u{2297} {br}Error running task {bw}"#{task.title}"{x}).c
191
+ def halted?
192
+ @results[:errors].positive? && !Howzit.options[:force]
193
+ end
137
194
 
138
- @results[:errors] += total
195
+ def confirm_prereqs(cols)
196
+ begin
197
+ puts TTY::Box.frame("{by}#{@prereqs.join("\n\n").wrap(cols - 4)}{x}".c, width: cols)
198
+ rescue Errno::EPIPE
199
+ # Pipe closed, ignore
200
+ end
201
+ res = Prompt.yn('Have the above prerequisites been met?', default: true)
202
+ Process.exit 1 unless res
203
+ end
139
204
 
140
- break unless Howzit.options[:force]
141
- end
205
+ def show_postreqs(cols)
206
+ return if @postreqs.empty?
142
207
 
143
- log_task_result(task, success)
144
- end
208
+ # Apply variable substitution to postreqs content, then wrap each line individually to preserve structure
209
+ postreqs_content = @postreqs.join("\n\n").render_arguments
210
+ wrapped_content = postreqs_content.split(/\n/).map { |line| line.wrap(cols - 4) }.join("\n")
211
+ puts TTY::Box.frame("{bw}#{wrapped_content}{x}".c, width: cols)
212
+ rescue Errno::EPIPE
213
+ # Pipe closed, ignore
214
+ end
145
215
 
146
- total = "{bw}#{@results[:total]}{by} #{@results[:total] == 1 ? 'task' : 'tasks'}".c
147
- errors = "{bw}#{@results[:errors]}{by} #{@results[:errors] == 1 ? 'error' : 'errors'}".c
148
- @results[:message] += if @results[:errors].zero?
149
- "{bg}\u{2713} {by}Ran #{total}{x}".c
150
- elsif Howzit.options[:force]
151
- "{br}\u{2715} {by}Completed #{total} with #{errors}{x}".c
152
- else
153
- "{br}\u{2715} {by}Ran #{total}, terminated due to error{x}".c
154
- end
216
+ def results_message
217
+ total = "{bw}#{@results[:total]}{by} #{@results[:total] == 1 ? 'task' : 'tasks'}".c
218
+ errors = "{bw}#{@results[:errors]}{by} #{@results[:errors] == 1 ? 'error' : 'errors'}".c
219
+ if @results[:errors].zero?
220
+ "{bg}\u{2713} {by}Ran #{total}{x}".c
221
+ elsif Howzit.options[:force]
222
+ "{br}\u{2715} {by}Completed #{total} with #{errors}{x}".c
155
223
  else
156
- Howzit.console.warn "{r}--run: No {br}@directive{xr} found in {bw}#{@title}{x}".c
224
+ "{br}\u{2715} {by}Ran #{total}, terminated due to error{x}".c
225
+ end
226
+ end
227
+
228
+ ##
229
+ ## Run tasks without conditional evaluation
230
+ ##
231
+ def run_tasks(output)
232
+ @tasks.each_with_index do |task, idx|
233
+ next if (task.optional || Howzit.options[:ask]) && !ask_task(task)
234
+
235
+ run_output, total, success = task.run
236
+
237
+ output.concat(run_output)
238
+ record_task_result(task, total, success)
239
+ next unless halted?
240
+
241
+ log_skipped_tasks(@tasks[(idx + 1)..])
242
+ break
243
+ end
244
+ output
245
+ end
246
+
247
+ ##
248
+ ## Add a task's results to the topic totals and the run report
249
+ ##
250
+ ## @param task [Task] The task that ran
251
+ ## @param total [Integer] Number of tasks it represents
252
+ ## @param success [Boolean] Whether it succeeded
253
+ ##
254
+ def record_task_result(task, total, success)
255
+ if task.type == :include && task.include_results
256
+ %i[total success errors].each { |key| @results[key] += task.include_results[key] }
257
+ else
258
+ @results[:total] += total
259
+ @results[success ? :success : :errors] += total
157
260
  end
158
261
 
159
- output.push(@results[:message]) if Howzit.options[:log_level] < 2 && !nested && !Howzit.options[:run]
262
+ log_task_result(task, success)
263
+ Howzit.console.warn %({bw}\u{2297} {br}Error running task {bw}"#{task.title}"{x}).c unless success
264
+ end
265
+
266
+ ##
267
+ ## Log tasks that won't run because an earlier task failed.
268
+ ## Include tasks are expanded to the tasks of the included topic.
269
+ ##
270
+ ## @param tasks [Array] Task objects
271
+ ## @param seen [Array] Titles of included topics already expanded
272
+ ##
273
+ def log_skipped_tasks(tasks, seen = [])
274
+ return unless Howzit.options[:run]
160
275
 
161
- unless @postreqs.empty?
162
- begin
163
- # Apply variable substitution to postreqs content, then wrap each line individually to preserve structure
164
- postreqs_content = @postreqs.join("\n\n").render_arguments
165
- wrapped_content = postreqs_content.split(/\n/).map { |line| line.wrap(cols - 4) }.join("\n")
166
- puts TTY::Box.frame("{bw}#{wrapped_content}{x}".c, width: cols)
167
- rescue Errno::EPIPE
168
- # Pipe closed, ignore
276
+ Howzit.run_log ||= []
277
+ tasks.each do |task|
278
+ if task.type == :include
279
+ topic = Howzit.buildnote.find_topic(task.action)[0]
280
+ next if topic.nil? || seen.include?(topic.title)
281
+
282
+ topic.log_skipped_tasks(topic.all_tasks, seen + [topic.title])
283
+ next
169
284
  end
285
+
286
+ topic_title = task.parent.is_a?(Topic) ? task.parent.title : @title
287
+ Howzit.run_log << { topic: topic_title, task: task_log_title(task), success: false, skipped: true }
170
288
  end
289
+ end
171
290
 
291
+ ##
292
+ ## Run each subtopic in order, adding its results to this topic's
293
+ ##
294
+ def run_subtopics(output)
295
+ @subtopics.each_with_index do |sub, idx|
296
+ output.concat(sub.run(nested: true, as_subtopic: true))
297
+ %i[total success errors].each { |key| @results[key] += sub.results[key] }
298
+ next unless halted?
299
+
300
+ log_skipped_tasks(@subtopics[(idx + 1)..].flat_map(&:all_tasks))
301
+ break
302
+ end
172
303
  output
173
304
  end
174
305
 
@@ -178,7 +309,7 @@ module Howzit
178
309
  end
179
310
 
180
311
  def colored_option(color, topic, keys)
181
- if topic.tasks.empty?
312
+ if topic.all_tasks.empty?
182
313
  ''
183
314
  else
184
315
  optional = keys[:optional] =~ /[?!]+/ ? true : false
@@ -302,7 +433,8 @@ module Howzit
302
433
  formatted_args = @arg_definitions.map { |arg| format_arg_definition(arg) }.join('{l}, '.c)
303
434
  header_title += " {l}({x}#{formatted_args}{l}){x}".c
304
435
  end
305
- output.push(header_title.format_header)
436
+ header_opts = opt[:subtopic] ? { color: '{bc}', hr: "\u{2508}" } : {}
437
+ output.push(header_title.format_header(header_opts))
306
438
  output.push('')
307
439
  end
308
440
  # Process conditional blocks first
@@ -333,6 +465,8 @@ module Howzit
333
465
  end
334
466
  Howzit.named_arguments = @named_args
335
467
  output.push('')
468
+ @subtopics.each { |sub| output.concat(sub.print_out(opt.merge(header: true, subtopic: true))) }
469
+ output
336
470
  end
337
471
 
338
472
  ##
@@ -447,22 +581,20 @@ module Howzit
447
581
  return if task.type == :include
448
582
 
449
583
  Howzit.run_log ||= []
450
-
451
- title = (task.title || '').strip
452
- if title.empty?
453
- action = (task.action || '').strip
454
- title = action.split(/\n/).first.to_s.strip
455
- end
456
- title = task.type.to_s.capitalize if title.nil? || title.empty?
457
-
458
584
  Howzit.run_log << {
459
585
  topic: @title,
460
- task: title,
586
+ task: task_log_title(task),
461
587
  success: success ? true : false,
462
588
  exit_status: task.last_status
463
589
  }
464
590
  end
465
591
 
592
+ def task_log_title(task)
593
+ title = (task.title || '').strip
594
+ title = (task.action || '').strip.split(/\n/).first.to_s.strip if title.empty?
595
+ title.empty? ? task.type.to_s.capitalize : title
596
+ end
597
+
466
598
  def gather_tasks
467
599
  runnable = []
468
600
  # Process conditional blocks first
@@ -727,7 +859,7 @@ module Howzit
727
859
  ##
728
860
  ## Run directives sequentially with conditional re-evaluation
729
861
  ##
730
- def run_sequential(nested: false, output: [], cols: 80)
862
+ def run_sequential(output: [])
731
863
  # Initialize conditional state
732
864
  conditional_state = {} # { index => { evaluated: bool, result: bool, matched_chain: bool } }
733
865
  directive_index = 0
@@ -737,16 +869,6 @@ module Howzit
737
869
  Howzit.named_arguments ||= {}
738
870
  Howzit.named_arguments.merge!(@named_args) if @named_args
739
871
 
740
- unless @prereqs.empty?
741
- begin
742
- puts TTY::Box.frame("{by}#{@prereqs.join("\n\n").wrap(cols - 4)}{x}".c, width: cols)
743
- rescue Errno::EPIPE
744
- # Pipe closed, ignore
745
- end
746
- res = Prompt.yn('Have the above prerequisites been met?', default: true)
747
- Process.exit 1 unless res
748
- end
749
-
750
872
  # Process directives sequentially
751
873
  while directive_index < @directives.length
752
874
  directive = @directives[directive_index]
@@ -887,48 +1009,18 @@ module Howzit
887
1009
  run_output, total, success = task.run
888
1010
 
889
1011
  output.concat(run_output)
890
- @results[:total] += total
891
-
892
- if success
893
- @results[:success] += total
894
- else
895
- Howzit.console.warn %({bw}\u{2297} {br}Error running task {bw}"#{task.title}"{x}).c
896
-
897
- @results[:errors] += total
898
-
899
- break unless Howzit.options[:force]
1012
+ record_task_result(task, total, success)
1013
+ if halted?
1014
+ # Tasks inside conditionals are omitted, since their conditions were never evaluated
1015
+ remaining = @directives[directive_index..].select { |d| d.task? && (d.conditional_path || []).empty? }
1016
+ log_skipped_tasks(remaining.filter_map { |d| d.to_task(self) })
1017
+ break
900
1018
  end
901
1019
 
902
- log_task_result(task, success)
903
-
904
1020
  # Re-evaluate all open conditionals after task execution
905
1021
  re_evaluate_conditionals(conditional_state, directive_index - 1, context)
906
1022
  end
907
1023
 
908
- if @results[:total].positive?
909
- total = "{bw}#{@results[:total]}{by} #{@results[:total] == 1 ? 'task' : 'tasks'}".c
910
- errors = "{bw}#{@results[:errors]}{by} #{@results[:errors] == 1 ? 'error' : 'errors'}".c
911
- @results[:message] += if @results[:errors].zero?
912
- "{bg}\u{2713} {by}Ran #{total}{x}".c
913
- elsif Howzit.options[:force]
914
- "{br}\u{2715} {by}Completed #{total} with #{errors}{x}".c
915
- else
916
- "{br}\u{2715} {by}Ran #{total}, terminated due to error{x}".c
917
- end
918
- output.push(@results[:message]) if Howzit.options[:log_level] < 2 && !nested && !Howzit.options[:run]
919
- end
920
-
921
- unless @postreqs.empty?
922
- begin
923
- # Apply variable substitution to postreqs content, then wrap each line individually to preserve structure
924
- postreqs_content = @postreqs.join("\n\n").render_arguments
925
- wrapped_content = postreqs_content.split(/\n/).map { |line| line.wrap(cols - 4) }.join("\n")
926
- puts TTY::Box.frame("{bw}#{wrapped_content}{x}".c, width: cols)
927
- rescue Errno::EPIPE
928
- # Pipe closed, ignore
929
- end
930
- end
931
-
932
1024
  output
933
1025
  end
934
1026
 
@@ -3,5 +3,5 @@
3
3
  # Primary module for this gem.
4
4
  module Howzit
5
5
  # Current Howzit version.
6
- VERSION = '2.1.42'
6
+ VERSION = '2.1.45'
7
7
  end
@@ -0,0 +1,100 @@
1
+ ---
2
+ title: Nested topics for Howzit
3
+ layout: post
4
+ tags: [howzit, ruby, markdown, cli, automation]
5
+ categories: [Blog,Code]
6
+ post_class: 'code'
7
+ comments: true
8
+ ---
9
+ [Howzit](https://github.com/ttscoff/howzit) has always split build notes into topics using Markdown headers. What it didn't do was care *which* header level you used. An `##` and a `###` were both just "a topic," and they all got flattened into one long list. As of version 2.1.44, that's changed: deeper headers now nest under the header above them, and that nesting affects both how notes display and what happens when you run them.
10
+
11
+ ### The short version
12
+
13
+ A `###` following a `##` is now a subtopic of that `##`. A `####` under the `###` is a subtopic of the `###`, and so on down. The next header at the same level (or shallower) closes out the parent.
14
+
15
+ ```markdown
16
+ ## Deploy
17
+
18
+ @run(./scripts/preflight.sh) Preflight checks
19
+
20
+ ### Build assets
21
+
22
+ @run(gulp) Build
23
+
24
+ ### Upload
25
+
26
+ @run(gulp sync) Sync to server
27
+
28
+ ## Package management
29
+ ```
30
+
31
+ Here "Build assets" and "Upload" belong to "Deploy." "Package management" is back at the top level.
32
+
33
+ ### Viewing a parent topic
34
+
35
+ When you pull up a parent topic, you now get the whole thing: the parent, followed by all of its subtopics.
36
+
37
+ ```console
38
+ $ howzit deploy
39
+ ```
40
+
41
+ Subtopic headers get a slightly lighter treatment (a dotted rule and a cyan title) so you can see where the nesting happens without it getting noisy. You can still ask for a subtopic directly:
42
+
43
+ ```console
44
+ $ howzit upload
45
+ ```
46
+
47
+ ...and you'll get just that section.
48
+
49
+ ### Running a parent topic
50
+
51
+ This is the part I actually wanted. Running a parent now runs everything under it:
52
+
53
+ ```console
54
+ $ howzit -r deploy
55
+ ```
56
+
57
+ That runs Deploy's own tasks first, then each subtopic's tasks in order, depth-first. So the example above runs preflight, then the build, then the sync. You get one combined summary at the end instead of a "Ran 1 task" line for every section.
58
+
59
+ A few details worth knowing:
60
+
61
+ - **Errors stop the run.** If a task fails, the remaining subtopics are skipped. Add `--force` if you want it to push through anyway.
62
+ - **`@before` and `@after` still work per topic.** Each topic shows its prerequisites before its own tasks run. A parent's `@after` block shows up once the parent *and* all of its subtopics are done, which is usually where you want that "don't forget to..." reminder.
63
+ - **Parents don't need tasks of their own.** A parent that's just an umbrella for a few subtopics runs fine, and it won't complain about "No @directive found."
64
+ - **Subtopics still run on their own.** `howzit -r upload` runs just the upload step.
65
+
66
+ ### Includes get the whole tree too
67
+
68
+ If another topic does `@include(Deploy)`, it now runs Deploy and all of its subtopics, same as running it directly. Task counts in include notes and menus reflect the full tree, so you're not surprised by how much is about to happen.
69
+
70
+ ### Listing and "show everything"
71
+
72
+ Running `howzit` with no arguments shows every topic. It now only prints top-level topics, since subtopics come along with their parents. No more seeing the same section twice.
73
+
74
+ `howzit -l` indents subtopics under their parents, so the list reflects the structure of your note:
75
+
76
+ ```console
77
+ $ howzit -l
78
+ Topics:
79
+
80
+ - Deploy
81
+ - Build assets
82
+ - Upload
83
+ - Package management
84
+ ```
85
+
86
+ Shell completions stay flat, so you can still tab-complete straight to a subtopic.
87
+
88
+ ### One thing to check in your notes
89
+
90
+ If you've been using `###` headers under `##` purely as visual formatting, those are now real subtopics. Viewing the parent will include them (probably what you wanted anyway), and running the parent will run their tasks too. If you had a `###` section with tasks you *don't* want run as part of its parent, bump it up to `##`.
91
+
92
+ ### Grab it
93
+
94
+ It's in 2.1.44:
95
+
96
+ ```console
97
+ $ gem install howzit
98
+ ```
99
+
100
+ The details are in the [build notes anatomy page](https://github.com/ttscoff/howzit/wiki/Anatomy-of-a-build-notes-file#subtopics) on the wiki. I've already started restructuring a few of my own notes around this. Having a single "Release" topic that fans out into build, test, and publish steps is a lot nicer than keeping them all at the same level and chaining them together with `@include`.
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'fileutils'
5
+ require 'tmpdir'
6
+
7
+ describe 'run report accuracy' do
8
+ let(:temp_dir) { File.expand_path(Dir.mktmpdir('howzit_run_report_test')) }
9
+ let(:buildnote) { Howzit::BuildNote.new }
10
+
11
+ before do
12
+ File.write(
13
+ File.join(temp_dir, 'buildnotes.md'),
14
+ <<~NOTE
15
+ # Run Report Project
16
+
17
+ ## Failing
18
+
19
+ @run(true) First
20
+ @run(false) Second
21
+ @run(true) Third
22
+
23
+ ## Release
24
+
25
+ @include(Failing)
26
+ @run(true) After include
27
+
28
+ ## Early Failure
29
+
30
+ @run(false) Fails first
31
+ @include(Failing)
32
+
33
+ ## Conditional
34
+
35
+ @if 1 == 1
36
+ @run(false) Conditional failure
37
+ @run(true) Conditional after
38
+ @end
39
+ @run(true) Unconditional after
40
+
41
+ ## Parent
42
+
43
+ @run(true) Parent task
44
+
45
+ ### Broken Child
46
+
47
+ @run(false) Child failure
48
+ @run(true) Child after
49
+
50
+ ### Later Child
51
+
52
+ @run(true) Later child task
53
+ NOTE
54
+ )
55
+
56
+ Dir.chdir(temp_dir)
57
+ Howzit.instance_variable_set(:@buildnote, buildnote)
58
+ Howzit.options[:stack] = false
59
+ Howzit.options[:include_upstream] = false
60
+ Howzit.options[:run] = true
61
+ Howzit.options[:force] = false
62
+ Howzit.run_log = []
63
+ end
64
+
65
+ after do
66
+ Howzit.options[:run] = false
67
+ Howzit.options[:force] = false
68
+ Howzit.run_log = []
69
+ Dir.chdir(Dir.tmpdir)
70
+ FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir)
71
+ Howzit.instance_variable_set(:@buildnote, nil)
72
+ end
73
+
74
+ def report
75
+ Howzit.run_log.map do |entry|
76
+ status = if entry[:skipped]
77
+ :skipped
78
+ else
79
+ entry[:success] ? :ok : :failed
80
+ end
81
+ [entry[:task], status]
82
+ end
83
+ end
84
+
85
+ it 'logs the failed task and marks the rest as skipped' do
86
+ buildnote.find_topic('Failing')[0].run
87
+
88
+ expect(report).to eq([['First', :ok], ['Second', :failed], ['Third', :skipped]])
89
+ expect(Howzit.run_log[1][:exit_status]).to eq(1)
90
+ end
91
+
92
+ it 'treats a failed include as a failure and skips the rest of the including topic' do
93
+ topic = buildnote.find_topic('Release')[0]
94
+ topic.run
95
+
96
+ expect(report).to eq([['First', :ok], ['Second', :failed], ['Third', :skipped], ['After include', :skipped]])
97
+ expect(topic.results).to include(total: 2, success: 1, errors: 1)
98
+ end
99
+
100
+ it 'expands skipped includes into the included topic tasks' do
101
+ buildnote.find_topic('Early Failure')[0].run
102
+
103
+ expect(report).to eq([['Fails first', :failed], ['First', :skipped], ['Second', :skipped], ['Third', :skipped]])
104
+ expect(Howzit.run_log[1][:topic]).to eq('Failing')
105
+ end
106
+
107
+ it 'skips only unconditional tasks after a failure in a conditional topic' do
108
+ buildnote.find_topic('Conditional')[0].run
109
+
110
+ expect(report).to eq([['Conditional failure', :failed], ['Unconditional after', :skipped]])
111
+ end
112
+
113
+ it 'skips remaining subtopics after a subtopic fails' do
114
+ buildnote.find_topic('Parent')[0].run
115
+
116
+ expect(report).to eq([
117
+ ['Parent task', :ok],
118
+ ['Child failure', :failed],
119
+ ['Child after', :skipped],
120
+ ['Later child task', :skipped]
121
+ ])
122
+ expect(Howzit.run_log.last[:topic]).to eq('Later Child')
123
+ end
124
+
125
+ it 'continues past failures with --force and logs every result' do
126
+ Howzit.options[:force] = true
127
+ topic = buildnote.find_topic('Release')[0]
128
+ topic.run
129
+
130
+ expect(report).to eq([['First', :ok], ['Second', :failed], ['Third', :ok], ['After include', :ok]])
131
+ expect(topic.results).to include(total: 4, success: 3, errors: 1)
132
+ end
133
+
134
+ describe 'multi-topic runs' do
135
+ before do
136
+ allow(buildnote).to receive(:finalize_output)
137
+ end
138
+
139
+ let(:topics) { ['Failing', 'Parent'].map { |title| buildnote.find_topic(title)[0] } }
140
+
141
+ it 'stops after a topic fails and skips the remaining topics' do
142
+ buildnote.send(:process_topic_matches, topics, [])
143
+
144
+ expect(report).to eq([
145
+ ['First', :ok],
146
+ ['Second', :failed],
147
+ ['Third', :skipped],
148
+ ['Parent task', :skipped],
149
+ ['Child failure', :skipped],
150
+ ['Child after', :skipped],
151
+ ['Later child task', :skipped]
152
+ ])
153
+ end
154
+
155
+ it 'runs every topic with --force' do
156
+ Howzit.options[:force] = true
157
+ buildnote.send(:process_topic_matches, topics, [])
158
+
159
+ expect(report.map(&:first)).to include('Parent task', 'Later child task')
160
+ expect(report.map(&:last)).not_to include(:skipped)
161
+ end
162
+ end
163
+
164
+ it 'formats skipped tasks in the report' do
165
+ buildnote.find_topic('Failing')[0].run
166
+ plain = Howzit::RunReport.format.uncolor
167
+
168
+ expect(plain).to include('❌ Second (exit code 1)')
169
+ expect(plain).to include('⏭️ Third (skipped)')
170
+ end
171
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'fileutils'
5
+ require 'tmpdir'
6
+
7
+ describe 'subtopics' do
8
+ let(:temp_dir) { File.expand_path(Dir.mktmpdir('howzit_subtopics_test')) }
9
+ let(:buildnote) { Howzit::BuildNote.new }
10
+ let(:parent) { buildnote.find_topic('Parent')[0] }
11
+
12
+ before do
13
+ File.write(
14
+ File.join(temp_dir, 'buildnotes.md'),
15
+ <<~NOTE
16
+ # Subtopic Project
17
+
18
+ ## Parent
19
+
20
+ Parent content
21
+
22
+ @run(echo parent) Parent task
23
+
24
+ ### Child One
25
+
26
+ @run(echo child1) Child one task
27
+
28
+ #### Grandchild
29
+
30
+ @run(echo grand) Grandchild task
31
+
32
+ ### Child Two
33
+
34
+ @run(echo child2) Child two task
35
+
36
+ ## Sibling
37
+
38
+ @run(echo sibling) Sibling task
39
+ NOTE
40
+ )
41
+
42
+ Dir.chdir(temp_dir)
43
+ Howzit.instance_variable_set(:@buildnote, nil)
44
+ Howzit.options[:stack] = false
45
+ Howzit.options[:include_upstream] = false
46
+ end
47
+
48
+ after do
49
+ Dir.chdir(Dir.tmpdir)
50
+ FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir)
51
+ Howzit.instance_variable_set(:@buildnote, nil)
52
+ end
53
+
54
+ def topic_named(title)
55
+ buildnote.topics.find { |t| t.title == title }
56
+ end
57
+
58
+ it 'nests deeper headers under the preceding shallower header' do
59
+ expect(parent.subtopics.map(&:title)).to eq(['Child One', 'Child Two'])
60
+ expect(topic_named('Child One').subtopics.map(&:title)).to eq(['Grandchild'])
61
+ expect(topic_named('Grandchild').ancestors.map(&:title)).to eq(['Child One', 'Parent'])
62
+ expect(topic_named('Sibling').parent_topic).to be_nil
63
+ end
64
+
65
+ it 'keeps subtopics selectable on their own' do
66
+ expect(buildnote.find_topic('Child Two')[0].title).to eq('Child Two')
67
+ end
68
+
69
+ it 'collects tasks from subtopics in order' do
70
+ expect(parent.all_tasks.map(&:title)).to eq(['Parent task', 'Child one task', 'Grandchild task', 'Child two task'])
71
+ end
72
+
73
+ it 'displays subtopics with the parent topic' do
74
+ output = parent.print_out.join("\n").uncolor
75
+
76
+ expect(output).to include('Parent content')
77
+ expect(output).to include('Child One')
78
+ expect(output).to include('Grandchild')
79
+ expect(output).to include('echo child2')
80
+ expect(output).not_to include('Sibling')
81
+ end
82
+
83
+ it 'only outputs top-level topics when showing all topics' do
84
+ expect(buildnote.top_level_topics.map(&:title)).to eq(%w[Parent Sibling])
85
+ end
86
+
87
+ it 'indents subtopics in the topic list' do
88
+ list = buildnote.list.uncolor
89
+
90
+ expect(list).to include("\n- Parent")
91
+ expect(list).to include("\n - Child One")
92
+ expect(list).to include("\n - Grandchild")
93
+ end
94
+
95
+ describe 'running a parent topic' do
96
+ let(:ran) { [] }
97
+
98
+ it 'runs its own tasks followed by all subtopic tasks' do
99
+ ran_titles = ran
100
+ allow_any_instance_of(Howzit::Task).to receive(:run) do |task|
101
+ ran_titles << task.title
102
+ [[], 1, true]
103
+ end
104
+
105
+ parent.run
106
+
107
+ expect(ran).to eq(['Parent task', 'Child one task', 'Grandchild task', 'Child two task'])
108
+ expect(parent.results[:total]).to eq(4)
109
+ end
110
+
111
+ it 'stops running subtopics after an error' do
112
+ ran_titles = ran
113
+ allow_any_instance_of(Howzit::Task).to receive(:run) do |task|
114
+ ran_titles << task.title
115
+ [[], 1, task.title != 'Child one task']
116
+ end
117
+
118
+ parent.run
119
+
120
+ expect(ran).to eq(['Parent task', 'Child one task'])
121
+ expect(parent.results[:errors]).to eq(1)
122
+ end
123
+
124
+ it 'continues past errors with --force' do
125
+ Howzit.options[:force] = true
126
+ ran_titles = ran
127
+ allow_any_instance_of(Howzit::Task).to receive(:run) do |task|
128
+ ran_titles << task.title
129
+ [[], 1, task.title != 'Child one task']
130
+ end
131
+
132
+ parent.run
133
+
134
+ expect(ran).to eq(['Parent task', 'Child one task', 'Grandchild task', 'Child two task'])
135
+ ensure
136
+ Howzit.options[:force] = false
137
+ end
138
+ end
139
+ end
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.42
4
+ version: 2.1.45
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brett Terpstra
@@ -13,14 +13,14 @@ dependencies:
13
13
  name: bundler
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - "~>"
16
+ - - ">="
17
17
  - !ruby/object:Gem::Version
18
18
  version: '2.2'
19
19
  type: :development
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
- - - "~>"
23
+ - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '2.2'
26
26
  - !ruby/object:Gem::Dependency
@@ -314,6 +314,7 @@ files:
314
314
  - lib/howzit/topic.rb
315
315
  - lib/howzit/util.rb
316
316
  - lib/howzit/version.rb
317
+ - nested-topics-for-howzit.md
317
318
  - scripts/runtests.sh
318
319
  - spec/buildnote_spec.rb
319
320
  - spec/cli_spec.rb
@@ -324,6 +325,7 @@ files:
324
325
  - spec/log_level_spec.rb
325
326
  - spec/prompt_spec.rb
326
327
  - spec/ruby_gem_spec.rb
328
+ - spec/run_report_accuracy_spec.rb
327
329
  - spec/run_report_spec.rb
328
330
  - spec/script_comm_spec.rb
329
331
  - spec/sequential_conditional_spec.rb
@@ -331,6 +333,7 @@ files:
331
333
  - spec/spec_helper.rb
332
334
  - spec/stack_mode_spec.rb
333
335
  - spec/stringutils_spec.rb
336
+ - spec/subtopics_spec.rb
334
337
  - spec/task_spec.rb
335
338
  - spec/topic_positional_snapshot_spec.rb
336
339
  - spec/topic_spec.rb
@@ -356,7 +359,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
356
359
  - !ruby/object:Gem::Version
357
360
  version: '0'
358
361
  requirements: []
359
- rubygems_version: 3.6.7
362
+ rubygems_version: 4.0.18
360
363
  specification_version: 4
361
364
  summary: Provides a way to access Markdown project notes by topic with query capabilities
362
365
  and the ability to execute the tasks it describes.
@@ -370,6 +373,7 @@ test_files:
370
373
  - spec/log_level_spec.rb
371
374
  - spec/prompt_spec.rb
372
375
  - spec/ruby_gem_spec.rb
376
+ - spec/run_report_accuracy_spec.rb
373
377
  - spec/run_report_spec.rb
374
378
  - spec/script_comm_spec.rb
375
379
  - spec/sequential_conditional_spec.rb
@@ -377,6 +381,7 @@ test_files:
377
381
  - spec/spec_helper.rb
378
382
  - spec/stack_mode_spec.rb
379
383
  - spec/stringutils_spec.rb
384
+ - spec/subtopics_spec.rb
380
385
  - spec/task_spec.rb
381
386
  - spec/topic_positional_snapshot_spec.rb
382
387
  - spec/topic_spec.rb