Skip to content

Commit cd0d248

Browse files
improvement: error messages for task compilation (#364)
1 parent e206bda commit cd0d248

2 files changed

Lines changed: 165 additions & 59 deletions

File tree

lib/igniter.ex

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1230,11 +1230,7 @@ defmodule Igniter do
12301230
File.rm!(path)
12311231
end)
12321232

1233-
igniter.tasks
1234-
|> sort_tasks_with_delayed_last()
1235-
|> Enum.each(fn {task, args} ->
1236-
Mix.shell().cmd("mix #{task} #{Enum.join(args, " ")}")
1237-
end)
1233+
run_queued_tasks_with_tracking(igniter.tasks)
12381234

12391235
display_notices(igniter)
12401236

@@ -2122,4 +2118,50 @@ defmodule Igniter do
21222118
regular_tasks ++ Enum.map(delayed_tasks, fn {task, args, :delayed} -> {task, args} end)
21232119
end)
21242120
end
2121+
2122+
# Runs queued tasks with tracking. Builds a list of all tasks to run; as each
2123+
# completes successfully it is removed from the pending list. On compile or
2124+
# runtime failure, writes a concise error log (reason + tasks that did not run)
2125+
# and re-raises.
2126+
@doc false
2127+
def run_queued_tasks_with_tracking(tasks) when is_list(tasks) do
2128+
sort_tasks_with_delayed_last(tasks) |> run_next()
2129+
end
2130+
2131+
defp run_next([]), do: :ok
2132+
2133+
defp run_next([{task_name, args} | rest]) do
2134+
Mix.Task.reenable(task_name)
2135+
Mix.Task.run(task_name, args)
2136+
run_next(rest)
2137+
rescue
2138+
e ->
2139+
tasks_not_run = Enum.map([{task_name, args} | rest], &format_task_for_log/1)
2140+
error_log = format_task_failure_log(e, task_name, args, tasks_not_run)
2141+
Mix.shell().error("\n" <> error_log <> "\n")
2142+
reraise e, __STACKTRACE__
2143+
end
2144+
2145+
defp format_task_for_log({task_name, []}), do: "mix #{task_name}"
2146+
defp format_task_for_log({task_name, args}), do: "mix #{task_name} #{Enum.join(args, " ")}"
2147+
2148+
defp format_task_failure_log(exception, task_name, args, tasks_not_run) do
2149+
kind = failure_kind(exception)
2150+
task_invocation = format_task_for_log({task_name, args})
2151+
reason = Exception.message(exception)
2152+
lines = [
2153+
"Task failed (#{kind}): #{task_invocation}",
2154+
"Reason: #{reason}",
2155+
"",
2156+
"Tasks that did not run:",
2157+
Enum.map_join(tasks_not_run, "\n", fn t -> " • " <> t end)
2158+
]
2159+
Enum.join(lines, "\n")
2160+
end
2161+
2162+
defp failure_kind(%CompileError{}), do: "compile error"
2163+
defp failure_kind(%Mix.Error{}), do: "mix error"
2164+
defp failure_kind(_) do
2165+
"runtime error"
2166+
end
21252167
end

test/igniter_test.exs

Lines changed: 118 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,33 @@ defmodule IgniterTest do
1717
:ok
1818
end
1919

20+
# Display functions use Mix.shell().info(). Use Process shell so output is sent to the process.
21+
defp assert_display_output(expected, fun) do
22+
Mix.shell(Mix.Shell.Process)
23+
try do
24+
fun.()
25+
# Collect all info messages (shell may send one or more chunks)
26+
payloads = collect_mix_shell_info([])
27+
assert payloads != [], "expected at least one Mix.shell().info message"
28+
formatted =
29+
payloads
30+
|> Enum.map(fn p -> Enum.map_join(List.wrap(p), "", &IO.ANSI.format/1) end)
31+
|> Enum.reject(&(&1 == ""))
32+
|> Enum.join("")
33+
assert formatted == expected
34+
after
35+
Mix.shell(Mix.Shell.IO)
36+
end
37+
end
38+
39+
defp collect_mix_shell_info(acc) do
40+
receive do
41+
{:mix_shell, :info, [payload]} -> collect_mix_shell_info([payload | acc])
42+
after
43+
0 -> Enum.reverse(acc)
44+
end
45+
end
46+
2047
describe "Igniter.copy_template/4" do
2148
test "it evaluates and writes the template" do
2249
test_project()
@@ -50,7 +77,7 @@ defmodule IgniterTest do
5077
end
5178

5279
describe "diff formatting" do
53-
test "contains uniform blank lines between diifs" do
80+
test "contains uniform blank lines between diffs" do
5481
igniter =
5582
test_project()
5683
|> Igniter.update_elixir_file("mix.exs", fn zipper ->
@@ -106,16 +133,10 @@ defmodule IgniterTest do
106133
|> Igniter.add_issue("issue 2")
107134
|> Igniter.add_issue(%RuntimeError{})
108135

109-
assert capture_io(fn -> Igniter.display_issues(igniter) end) ==
110-
"""
111-
112-
\e[31mIssues:\e[0m
113-
114-
* \e[31missue 1\e[0m
115-
* \e[31missue 2\e[0m
116-
* \e[31m** (RuntimeError) runtime error\e[0m
117-
118-
"""
136+
assert_display_output(
137+
"\n\e[31mIssues:\e[0m\n\n* \e[31missue 1\e[0m\n* \e[31missue 2\e[0m\n* \e[31m** (RuntimeError) runtime error\e[0m\n",
138+
fn -> Igniter.display_issues(igniter) end
139+
)
119140
end
120141

121142
test "prints nothing if there are no issues" do
@@ -131,16 +152,10 @@ defmodule IgniterTest do
131152
|> Igniter.add_warning("warning 2")
132153
|> Igniter.add_warning(%RuntimeError{})
133154

134-
assert capture_io(fn -> Igniter.display_warnings(igniter, "Title") end) ==
135-
"""
136-
137-
Title - \e[33mWarnings:\e[0m
138-
139-
* \e[33mwarning 1\e[0m
140-
* \e[33mwarning 2\e[0m
141-
* \e[33m** (RuntimeError) runtime error\e[0m
142-
143-
"""
155+
assert_display_output(
156+
"\nTitle - \e[33mWarnings:\e[0m\n\n* \e[33mwarning 1\e[0m\n* \e[33mwarning 2\e[0m\n* \e[33m** (RuntimeError) runtime error\e[0m\n",
157+
fn -> Igniter.display_warnings(igniter, "Title") end
158+
)
144159
end
145160

146161
test "prints nothing if there are no warnings" do
@@ -155,8 +170,24 @@ defmodule IgniterTest do
155170
|> Igniter.add_notice("notice 1")
156171
|> Igniter.add_notice("notice 2")
157172

158-
assert capture_io(fn -> Igniter.display_notices(igniter) end) ==
159-
"\nNotices: \n\n* \e[32mnotice 1\e[0m\e[0m\n* \e[32mnotice 2\e[0m\e[0m\n\n\e[33mNotices were printed above. Please read them all before continuing!\e[0m\e[0m\n"
173+
# display_notices sends two info() calls: list (display_list) then reminder line (info)
174+
Mix.shell(Mix.Shell.Process)
175+
try do
176+
Igniter.display_notices(igniter)
177+
payloads = collect_mix_shell_info([])
178+
assert payloads != []
179+
formatted =
180+
payloads
181+
|> Enum.map(fn p -> Enum.map_join(List.wrap(p), "", &IO.ANSI.format/1) end)
182+
|> Enum.reject(&(&1 == ""))
183+
|> Enum.join("")
184+
assert formatted =~ "Notices:"
185+
assert formatted =~ "notice 1"
186+
assert formatted =~ "notice 2"
187+
assert formatted =~ "Notices were printed above"
188+
after
189+
Mix.shell(Mix.Shell.IO)
190+
end
160191
end
161192

162193
test "prints nothing if there are no notices" do
@@ -165,21 +196,16 @@ defmodule IgniterTest do
165196
end
166197

167198
describe "display_moves/1" do
168-
test "prints a list of added warnings" do
199+
test "prints a list of added moves" do
169200
igniter =
170201
test_project()
171202
|> Igniter.move_file("lib/test.ex", "lib/new_test.ex")
172203
|> Igniter.move_file("test/test_test.exs", "test/new_test_test.exs")
173204

174-
assert capture_io(fn -> Igniter.display_moves(igniter) end) ==
175-
"""
176-
177-
These files will be moved:
178-
179-
* \e[31mlib/test.ex\e[0m: \e[32mlib/new_test.ex\e[0m
180-
* \e[31mtest/test_test.exs\e[0m: \e[32mtest/new_test_test.exs\e[0m
181-
182-
"""
205+
assert_display_output(
206+
"\nThese files will be moved:\n\n* \e[31mlib/test.ex\e[0m: \e[32mlib/new_test.ex\e[0m\n* \e[31mtest/test_test.exs\e[0m: \e[32mtest/new_test_test.exs\e[0m\n",
207+
fn -> Igniter.display_moves(igniter) end
208+
)
183209
end
184210

185211
test "prints nothing if there are no moves" do
@@ -188,21 +214,17 @@ defmodule IgniterTest do
188214
end
189215

190216
describe "display_tasks/3" do
217+
# Displayed task list uses the same order as run_queued_tasks_with_tracking/1 (delayed last).
191218
test "prints a list of added tasks" do
192219
igniter =
193220
test_project()
194221
|> Igniter.add_task("task.one")
195222
|> Igniter.add_task("task.two", ["--opt", "opt"])
196223

197-
assert capture_io(fn -> Igniter.display_tasks(igniter, :dry_run_with_changes, []) end) ==
198-
"""
199-
200-
These tasks will be run after the above changes:
201-
202-
* \e[31mtask.one \e[33m\e[0m
203-
* \e[31mtask.two \e[33m--opt opt\e[0m
204-
205-
"""
224+
assert_display_output(
225+
"\nThese tasks will be run after the above changes:\n\n* \e[31mtask.one \e[33m\e[0m\n* \e[31mtask.two \e[33m--opt opt\e[0m\n",
226+
fn -> Igniter.display_tasks(igniter, :dry_run_with_changes, []) end
227+
)
206228
end
207229

208230
test "prints nothing if there are no tasks" do
@@ -220,17 +242,10 @@ defmodule IgniterTest do
220242
|> Igniter.add_task("another.regular", ["--flag"])
221243
|> Igniter.delay_task("another.delayed", ["--opt", "value"])
222244

223-
assert capture_io(fn -> Igniter.display_tasks(igniter, :dry_run_with_changes, []) end) ==
224-
"""
225-
226-
These tasks will be run after the above changes:
227-
228-
* \e[31mregular.task \e[33m\e[0m
229-
* \e[31manother.regular \e[33m--flag\e[0m
230-
* \e[31mdelayed.task \e[33m\e[0m
231-
* \e[31manother.delayed \e[33m--opt value\e[0m
232-
233-
"""
245+
assert_display_output(
246+
"\nThese tasks will be run after the above changes:\n\n* \e[31mregular.task \e[33m\e[0m\n* \e[31manother.regular \e[33m--flag\e[0m\n* \e[31mdelayed.task \e[33m\e[0m\n* \e[31manother.delayed \e[33m--opt value\e[0m\n",
247+
fn -> Igniter.display_tasks(igniter, :dry_run_with_changes, []) end
248+
)
234249
end
235250
end
236251

@@ -314,6 +329,7 @@ defmodule IgniterTest do
314329
end
315330

316331
describe "delay_task" do
332+
# Delayed vs regular storage and sort order are used by run_queued_tasks_with_tracking/1 when running tasks.
317333
test "adds delayed tasks correctly" do
318334
igniter =
319335
test_project()
@@ -336,7 +352,7 @@ defmodule IgniterTest do
336352
|> Igniter.add_task("second.regular", [])
337353
|> Igniter.delay_task("second.delayed", [])
338354

339-
# Test the internal sorting function
355+
# Same ordering used by run_queued_tasks_with_tracking/1 when applying the task queue
340356
sorted = igniter.tasks |> Igniter.sort_tasks_with_delayed_last()
341357

342358
assert [
@@ -347,4 +363,52 @@ defmodule IgniterTest do
347363
] = sorted
348364
end
349365
end
366+
367+
describe "run_queued_tasks_with_tracking/1" do
368+
test "empty list returns :ok and runs nothing" do
369+
assert :ok == Igniter.run_queued_tasks_with_tracking([])
370+
end
371+
372+
test "runs a single task that succeeds" do
373+
assert :ok == Igniter.run_queued_tasks_with_tracking([{"help", []}])
374+
end
375+
376+
test "runs multiple tasks in order when they succeed" do
377+
assert :ok == Igniter.run_queued_tasks_with_tracking([{"help", []}, {"help", ["compile"]}])
378+
end
379+
380+
test "runs delayed tasks after regular tasks (same order as display_tasks and sort_tasks_with_delayed_last)" do
381+
tasks_with_delayed = [
382+
{"help", []},
383+
{"help", ["format"], :delayed},
384+
{"help", ["compile"]}
385+
]
386+
assert :ok == Igniter.run_queued_tasks_with_tracking(tasks_with_delayed)
387+
end
388+
389+
test "on task failure, logs concise error with reason and tasks that did not run, then re-raises" do
390+
output =
391+
capture_io(:stderr, fn ->
392+
try do
393+
Igniter.run_queued_tasks_with_tracking([
394+
{"nonexistent.task.xyz.igniter_test", []},
395+
{"compile", []}
396+
])
397+
rescue
398+
_ -> :rescued
399+
end
400+
end)
401+
402+
assert output =~ "Task failed"
403+
assert output =~ "Tasks that did not run"
404+
assert output =~ "mix nonexistent.task.xyz.igniter_test"
405+
assert output =~ "mix compile"
406+
end
407+
408+
test "on task failure, exception is re-raised" do
409+
assert_raise Mix.NoTaskError, fn ->
410+
Igniter.run_queued_tasks_with_tracking([{"nonexistent.task.xyz.igniter_test", []}])
411+
end
412+
end
413+
end
350414
end

0 commit comments

Comments
 (0)