From a7f611cdff7bb2a9dbce76b9eb4a194be148c05a Mon Sep 17 00:00:00 2001 From: Rolf Malthe Andersen Date: Thu, 11 Dec 2025 12:41:01 +0100 Subject: [PATCH 1/3] feat: add engine subcommands to expert --- apps/expert/lib/expert/application.ex | 18 +- apps/expert/lib/expert/engine.ex | 157 +++++++++++++++++ apps/expert/test/expert/engine_test.exs | 213 ++++++++++++++++++++++++ 3 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 apps/expert/lib/expert/engine.ex create mode 100644 apps/expert/test/expert/engine_test.exs diff --git a/apps/expert/lib/expert/application.ex b/apps/expert/lib/expert/application.ex index fc9b81f1..0c33b2b9 100644 --- a/apps/expert/lib/expert/application.ex +++ b/apps/expert/lib/expert/application.ex @@ -12,8 +12,19 @@ defmodule Expert.Application do @impl true def start(_type, _args) do + argv = Burrito.Util.Args.argv() + + # Handle engine subcommand first (before starting the LSP server) + case argv do + ["engine" | engine_args] -> + Expert.Engine.run(engine_args) + + _ -> + :noop + end + {opts, _argv, _invalid} = - OptionParser.parse(Burrito.Util.Args.argv(), + OptionParser.parse(argv, strict: [version: :boolean, help: :boolean, stdio: :boolean, port: :integer] ) @@ -26,6 +37,7 @@ defmodule Expert.Application do Source code: https://github.com/elixir-lang/expert expert [flags] + expert engine [options] #{IO.ANSI.bright()}FLAGS#{IO.ANSI.reset()} @@ -33,6 +45,10 @@ defmodule Expert.Application do --port Use TCP as the transport mechanism, with the given port --help Show this help message --version Show Expert version + + #{IO.ANSI.bright()}SUBCOMMANDS#{IO.ANSI.reset()} + + engine Manage engine builds (use 'expert engine --help' for details) """ cond do diff --git a/apps/expert/lib/expert/engine.ex b/apps/expert/lib/expert/engine.ex new file mode 100644 index 00000000..6ed5e38d --- /dev/null +++ b/apps/expert/lib/expert/engine.ex @@ -0,0 +1,157 @@ +defmodule Expert.Engine do + @moduledoc """ + Utilities for managing Expert engine builds. + + When Expert builds the engine for a project using Mix.install, it caches + the build in the user data directory. If engine dependencies change (e.g., + in nightly builds), Mix.install may not know to rebuild, causing errors. + + This module provides functions to inspect and clean these cached builds. + """ + + @doc """ + Runs engine management commands based on parsed arguments. + + Returns :ok and halts the system after executing the command. + """ + @spec run([String.t()]) :: :ok + def run(args) do + {opts, subcommand, _invalid} = + OptionParser.parse(args, + strict: [force: :boolean], + aliases: [f: :force] + ) + + case subcommand do + ["ls"] -> list_engines() + ["clean"] -> clean_engines(opts) + _ -> print_help() + end + end + + defp list_engines() do + case get_engine_dirs() do + [] -> + IO.puts("No engine builds found.") + print_location_info() + + dirs -> + Enum.each(dirs, &IO.puts/1) + end + + System.halt(0) + end + + defp clean_engines(opts) do + case get_engine_dirs() do + [] -> + IO.puts("No engine builds found.") + print_location_info() + System.halt(0) + + dirs -> + if opts[:force] do + clean_all_force(dirs) + else + clean_interactive(dirs) + end + end + end + + defp base_dir do + base = :filename.basedir(:user_data, ~c"Expert") + to_string(base) + end + + defp get_engine_dirs do + base = base_dir() + + if File.exists?(base) do + base + |> File.ls!() + |> Enum.map(&Path.join(base, &1)) + |> Enum.filter(&File.dir?/1) + |> Enum.sort() + else + [] + end + end + + defp clean_all_force(dirs) do + Enum.each(dirs, fn dir -> + case File.rm_rf(dir) do + {:ok, _} -> + IO.puts("Deleted #{dir}") + + {:error, reason, file} -> + IO.puts(:stderr, "Error deleting #{file}: #{inspect(reason)}") + end + end) + + System.halt(0) + end + + defp clean_interactive(dirs) do + Enum.each(dirs, fn dir -> + answer = prompt_delete(dir) + + if answer do + case File.rm_rf(dir) do + {:ok, _} -> + :ok + + {:error, reason, file} -> + IO.puts(:stderr, "Error deleting #{file}: #{inspect(reason)}") + end + end + end) + + System.halt(0) + end + + defp prompt_delete(dir) do + IO.puts(["Delete #{dir}", IO.ANSI.red(), "?", IO.ANSI.reset(), " [Yn] "]) + + input = + IO.gets("") + |> String.trim() + |> String.downcase() + + case input do + "" -> true + "y" -> true + "yes" -> true + _ -> false + end + end + + defp print_location_info do + IO.puts("\nEngine builds are stored in: #{base_dir()}") + end + + defp print_help do + IO.puts(""" + Expert Engine Management + + Manage cached engine builds created by Mix.install. Use these commands + to resolve dependency errors or free up disk space. + + USAGE: + expert engine [options] + + SUBCOMMANDS: + ls List all engine build directories + clean Interactively delete engine build directories + + OPTIONS: + -f, --force Delete all builds without prompting (clean only) + + EXAMPLES: + expert engine ls + expert engine clean + expert engine clean --force + """) + + System.halt(0) + end +end diff --git a/apps/expert/test/expert/engine_test.exs b/apps/expert/test/expert/engine_test.exs new file mode 100644 index 00000000..fd2b050c --- /dev/null +++ b/apps/expert/test/expert/engine_test.exs @@ -0,0 +1,213 @@ +defmodule Expert.EngineTest do + use ExUnit.Case, async: false + use Patch + + alias Expert.Engine + + import ExUnit.CaptureIO + + @test_base_dir "test_engine_builds" + + setup do + File.mkdir_p!(@test_base_dir) + + patch(Engine, :base_dir, @test_base_dir) + + patch(System, :halt, fn _code -> :ok end) + + on_exit(fn -> + if File.exists?(@test_base_dir) do + File.rm_rf!(@test_base_dir) + end + end) + + :ok + end + + describe "run/1 - ls subcommand" do + test "lists nothing when no engine builds exist" do + output = + capture_io(fn -> + Engine.run(["ls"]) + end) + + assert output =~ "No engine builds found." + end + + test "lists engine directories" do + File.mkdir_p!(Path.join(@test_base_dir, "0.1.0")) + File.mkdir_p!(Path.join(@test_base_dir, "0.2.0")) + + output = + capture_io(fn -> + Engine.run(["ls"]) + end) + + assert output =~ "0.1.0" + assert output =~ "0.2.0" + end + end + + describe "run/1 - clean subcommand with --force" do + test "deletes all engine directories without prompting" do + dir1 = Path.join(@test_base_dir, "0.1.0") + dir2 = Path.join(@test_base_dir, "0.2.0") + File.mkdir_p!(dir1) + File.mkdir_p!(dir2) + + assert File.exists?(dir1) + assert File.exists?(dir2) + + output = + capture_io(fn -> + Engine.run(["clean", "--force"]) + end) + + assert output =~ "Deleted" + assert output =~ dir1 + assert output =~ dir2 + + refute File.exists?(dir1) + refute File.exists?(dir2) + end + + test "deletes all engine directories with -f short flag" do + dir1 = Path.join(@test_base_dir, "0.1.0") + File.mkdir_p!(dir1) + + capture_io(fn -> + Engine.run(["clean", "-f"]) + end) + + refute File.exists?(dir1) + end + + test "handles deletion errors gracefully" do + dir1 = Path.join(@test_base_dir, "0.1.0") + File.mkdir_p!(dir1) + + # Mock File.rm_rf to return an error + patch(File, :rm_rf, fn _path -> + {:error, :eacces, dir1} + end) + + output = + capture_io(:stderr, fn -> + capture_io(fn -> + Engine.run(["clean", "--force"]) + end) + end) + + assert output =~ "Error deleting" + assert output =~ dir1 + end + end + + describe "run/1 - clean subcommand interactive mode" do + test "deletes directory when user confirms with 'y'" do + dir1 = Path.join(@test_base_dir, "0.1.0") + File.mkdir_p!(dir1) + + assert File.exists?(dir1) + + capture_io([input: "y\n"], fn -> + Engine.run(["clean"]) + end) + + refute File.exists?(dir1) + end + + test "deletes directory when user confirms with 'yes'" do + dir1 = Path.join(@test_base_dir, "0.1.0") + File.mkdir_p!(dir1) + + capture_io([input: "yes\n"], fn -> + Engine.run(["clean"]) + end) + + refute File.exists?(dir1) + end + + test "deletes directory when user presses enter (default yes)" do + dir1 = Path.join(@test_base_dir, "0.1.0") + File.mkdir_p!(dir1) + + capture_io([input: "\n"], fn -> + Engine.run(["clean"]) + end) + + refute File.exists?(dir1) + end + + test "keeps directory when user declines with 'n'" do + dir1 = Path.join(@test_base_dir, "0.1.0") + File.mkdir_p!(dir1) + + capture_io([input: "n\n"], fn -> + Engine.run(["clean"]) + end) + + assert File.exists?(dir1) + end + + test "keeps directory when user declines with 'no'" do + dir1 = Path.join(@test_base_dir, "0.1.0") + File.mkdir_p!(dir1) + + capture_io([input: "no\n"], fn -> + Engine.run(["clean"]) + end) + + assert File.exists?(dir1) + end + + test "keeps directory when user enters any other text" do + dir1 = Path.join(@test_base_dir, "0.1.0") + File.mkdir_p!(dir1) + + capture_io([input: "maybe\n"], fn -> + Engine.run(["clean"]) + end) + + assert File.exists?(dir1) + end + + test "handles multiple directories with mixed responses" do + dir1 = Path.join(@test_base_dir, "0.1.0") + dir2 = Path.join(@test_base_dir, "0.2.0") + dir3 = Path.join(@test_base_dir, "0.3.0") + File.mkdir_p!(dir1) + File.mkdir_p!(dir2) + File.mkdir_p!(dir3) + + # Answer yes to first, no to second, yes to third + capture_io([input: "y\nn\nyes\n"], fn -> + Engine.run(["clean"]) + end) + + refute File.exists?(dir1) + assert File.exists?(dir2) + refute File.exists?(dir3) + end + + test "prints message when no engine builds exist" do + output = + capture_io([input: "\n"], fn -> + Engine.run(["clean"]) + end) + + assert output =~ "No engine builds found." + end + end + + describe "run/1 - help and unknown commands" do + test "prints help for unknown subcommand" do + output = + capture_io(fn -> + Engine.run(["unknown"]) + end) + + assert output =~ "Expert Engine Management" + end + end +end From a7a5bdbf5090bf7319f04eeb896613de709d7145 Mon Sep 17 00:00:00 2001 From: rma97 Date: Thu, 11 Dec 2025 13:35:17 +0100 Subject: [PATCH 2/3] fix credo and dialyzer errors set no_return() to run/1 --- apps/expert/lib/expert/engine.ex | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/expert/lib/expert/engine.ex b/apps/expert/lib/expert/engine.ex index 6ed5e38d..12b70871 100644 --- a/apps/expert/lib/expert/engine.ex +++ b/apps/expert/lib/expert/engine.ex @@ -14,7 +14,7 @@ defmodule Expert.Engine do Returns :ok and halts the system after executing the command. """ - @spec run([String.t()]) :: :ok + @spec run([String.t()]) :: no_return() def run(args) do {opts, subcommand, _invalid} = OptionParser.parse(args, @@ -29,7 +29,8 @@ defmodule Expert.Engine do end end - defp list_engines() do + @spec list_engines() :: no_return() + defp list_engines do case get_engine_dirs() do [] -> IO.puts("No engine builds found.") @@ -42,6 +43,7 @@ defmodule Expert.Engine do System.halt(0) end + @spec clean_engines(keyword()) :: no_return() defp clean_engines(opts) do case get_engine_dirs() do [] -> @@ -77,6 +79,7 @@ defmodule Expert.Engine do end end + @spec clean_all_force([String.t()]) :: no_return() defp clean_all_force(dirs) do Enum.each(dirs, fn dir -> case File.rm_rf(dir) do @@ -91,6 +94,7 @@ defmodule Expert.Engine do System.halt(0) end + @spec clean_interactive([String.t()]) :: no_return() defp clean_interactive(dirs) do Enum.each(dirs, fn dir -> answer = prompt_delete(dir) @@ -113,7 +117,8 @@ defmodule Expert.Engine do IO.puts(["Delete #{dir}", IO.ANSI.red(), "?", IO.ANSI.reset(), " [Yn] "]) input = - IO.gets("") + "" + |> IO.gets() |> String.trim() |> String.downcase() @@ -129,6 +134,7 @@ defmodule Expert.Engine do IO.puts("\nEngine builds are stored in: #{base_dir()}") end + @spec print_help() :: no_return() defp print_help do IO.puts(""" Expert Engine Management From 36b2874a7e527ecbe351f3d60ce691202a541fbc Mon Sep 17 00:00:00 2001 From: rma97 Date: Mon, 15 Dec 2025 08:13:19 +0100 Subject: [PATCH 3/3] let Engine return 1 if file deletion fails format --- apps/expert/lib/expert/application.ex | 4 +- apps/expert/lib/expert/engine.ex | 131 +++++++++++++++++------ apps/expert/test/expert/engine_test.exs | 136 +++++++++++++++++++++--- 3 files changed, 218 insertions(+), 53 deletions(-) diff --git a/apps/expert/lib/expert/application.ex b/apps/expert/lib/expert/application.ex index 0c33b2b9..b80f05b7 100644 --- a/apps/expert/lib/expert/application.ex +++ b/apps/expert/lib/expert/application.ex @@ -17,7 +17,9 @@ defmodule Expert.Application do # Handle engine subcommand first (before starting the LSP server) case argv do ["engine" | engine_args] -> - Expert.Engine.run(engine_args) + engine_args + |> Expert.Engine.run() + |> System.halt() _ -> :noop diff --git a/apps/expert/lib/expert/engine.ex b/apps/expert/lib/expert/engine.ex index 12b70871..8fffe31d 100644 --- a/apps/expert/lib/expert/engine.ex +++ b/apps/expert/lib/expert/engine.ex @@ -12,9 +12,16 @@ defmodule Expert.Engine do @doc """ Runs engine management commands based on parsed arguments. - Returns :ok and halts the system after executing the command. + Returns the exit code for the command. Clean operations will stop at the + first deletion error and return exit code 1. """ - @spec run([String.t()]) :: no_return() + + @success_code 0 + @error_code 1 + + @help_options ["-h", "--help"] + + @spec run([String.t()]) :: non_neg_integer() def run(args) do {opts, subcommand, _invalid} = OptionParser.parse(args, @@ -24,12 +31,14 @@ defmodule Expert.Engine do case subcommand do ["ls"] -> list_engines() + ["ls", options] when options in @help_options -> print_ls_help() ["clean"] -> clean_engines(opts) + ["clean", options] when options in @help_options -> print_clean_help() _ -> print_help() end end - @spec list_engines() :: no_return() + @spec list_engines() :: non_neg_integer() defp list_engines do case get_engine_dirs() do [] -> @@ -40,16 +49,16 @@ defmodule Expert.Engine do Enum.each(dirs, &IO.puts/1) end - System.halt(0) + @success_code end - @spec clean_engines(keyword()) :: no_return() + @spec clean_engines(keyword()) :: non_neg_integer() defp clean_engines(opts) do case get_engine_dirs() do [] -> IO.puts("No engine builds found.") print_location_info() - System.halt(0) + @success_code dirs -> if opts[:force] do @@ -79,38 +88,53 @@ defmodule Expert.Engine do end end - @spec clean_all_force([String.t()]) :: no_return() + @spec clean_all_force([String.t()]) :: non_neg_integer() + # Deletes all directories without prompting. Stops on first error and returns 1. defp clean_all_force(dirs) do - Enum.each(dirs, fn dir -> - case File.rm_rf(dir) do - {:ok, _} -> - IO.puts("Deleted #{dir}") - - {:error, reason, file} -> - IO.puts(:stderr, "Error deleting #{file}: #{inspect(reason)}") - end - end) - - System.halt(0) - end - - @spec clean_interactive([String.t()]) :: no_return() - defp clean_interactive(dirs) do - Enum.each(dirs, fn dir -> - answer = prompt_delete(dir) - - if answer do + result = + Enum.reduce_while(dirs, :ok, fn dir, _acc -> case File.rm_rf(dir) do {:ok, _} -> - :ok + IO.puts("Deleted #{dir}") + {:cont, :ok} {:error, reason, file} -> IO.puts(:stderr, "Error deleting #{file}: #{inspect(reason)}") + {:halt, :error} + end + end) + + case result do + :ok -> @success_code + :error -> @error_code + end + end + + @spec clean_interactive([String.t()]) :: non_neg_integer() + # Prompts the user for each directory deletion. Stops on first error and returns 1. + defp clean_interactive(dirs) do + result = + Enum.reduce_while(dirs, :ok, fn dir, _acc -> + answer = prompt_delete(dir) + + if answer do + case File.rm_rf(dir) do + {:ok, _} -> + {:cont, :ok} + + {:error, reason, file} -> + IO.puts(:stderr, "Error deleting #{file}: #{inspect(reason)}") + {:halt, :error} + end + else + {:cont, :ok} end - end - end) + end) - System.halt(0) + case result do + :ok -> @success_code + :error -> @error_code + end end defp prompt_delete(dir) do @@ -134,7 +158,7 @@ defmodule Expert.Engine do IO.puts("\nEngine builds are stored in: #{base_dir()}") end - @spec print_help() :: no_return() + @spec print_help() :: non_neg_integer() defp print_help do IO.puts(""" Expert Engine Management @@ -143,21 +167,58 @@ defmodule Expert.Engine do to resolve dependency errors or free up disk space. USAGE: - expert engine [options] + expert engine SUBCOMMANDS: ls List all engine build directories clean Interactively delete engine build directories - OPTIONS: - -f, --force Delete all builds without prompting (clean only) + Use 'expert engine --help' for more information on a specific command. EXAMPLES: expert engine ls expert engine clean + """) + + @success_code + end + + @spec print_ls_help() :: non_neg_integer() + defp print_ls_help do + IO.puts(""" + List Engine Builds + + List all cached engine build directories. + + USAGE: + expert engine ls + + EXAMPLES: + expert engine ls + """) + + @success_code + end + + @spec print_clean_help() :: non_neg_integer() + defp print_clean_help do + IO.puts(""" + Clean Engine Builds + + Interactively delete cached engine build directories. By default, you will + be prompted to confirm deletion of each build. Use --force to skip prompts. + + USAGE: + expert engine clean [options] + + OPTIONS: + -f, --force Delete all builds without prompting + + EXAMPLES: + expert engine clean expert engine clean --force """) - System.halt(0) + @success_code end end diff --git a/apps/expert/test/expert/engine_test.exs b/apps/expert/test/expert/engine_test.exs index fd2b050c..c8db29c3 100644 --- a/apps/expert/test/expert/engine_test.exs +++ b/apps/expert/test/expert/engine_test.exs @@ -13,8 +13,6 @@ defmodule Expert.EngineTest do patch(Engine, :base_dir, @test_base_dir) - patch(System, :halt, fn _code -> :ok end) - on_exit(fn -> if File.exists?(@test_base_dir) do File.rm_rf!(@test_base_dir) @@ -28,7 +26,8 @@ defmodule Expert.EngineTest do test "lists nothing when no engine builds exist" do output = capture_io(fn -> - Engine.run(["ls"]) + exit_code = Engine.run(["ls"]) + assert exit_code == 0 end) assert output =~ "No engine builds found." @@ -40,7 +39,8 @@ defmodule Expert.EngineTest do output = capture_io(fn -> - Engine.run(["ls"]) + exit_code = Engine.run(["ls"]) + assert exit_code == 0 end) assert output =~ "0.1.0" @@ -60,7 +60,8 @@ defmodule Expert.EngineTest do output = capture_io(fn -> - Engine.run(["clean", "--force"]) + exit_code = Engine.run(["clean", "--force"]) + assert exit_code == 0 end) assert output =~ "Deleted" @@ -76,13 +77,14 @@ defmodule Expert.EngineTest do File.mkdir_p!(dir1) capture_io(fn -> - Engine.run(["clean", "-f"]) + exit_code = Engine.run(["clean", "-f"]) + assert exit_code == 0 end) refute File.exists?(dir1) end - test "handles deletion errors gracefully" do + test "stops on first deletion error and returns error code 1" do dir1 = Path.join(@test_base_dir, "0.1.0") File.mkdir_p!(dir1) @@ -94,13 +96,50 @@ defmodule Expert.EngineTest do output = capture_io(:stderr, fn -> capture_io(fn -> - Engine.run(["clean", "--force"]) + exit_code = Engine.run(["clean", "--force"]) + assert exit_code == 1 end) end) assert output =~ "Error deleting" assert output =~ dir1 end + + test "stops deleting after first error" do + dir1 = Path.join(@test_base_dir, "0.1.0") + dir2 = Path.join(@test_base_dir, "0.2.0") + dir3 = Path.join(@test_base_dir, "0.3.0") + File.mkdir_p!(dir1) + File.mkdir_p!(dir2) + File.mkdir_p!(dir3) + + # Track which directories were attempted + {:ok, agent_pid} = Agent.start_link(fn -> [] end) + + # Fail on the second directory + patch(File, :rm_rf, fn path -> + :ok = Agent.update(agent_pid, fn list -> [path | list] end) + + cond do + String.ends_with?(path, "0.1.0") -> {:ok, []} + String.ends_with?(path, "0.2.0") -> {:error, :eacces, path} + true -> {:ok, []} + end + end) + + capture_io(:stderr, fn -> + capture_io(fn -> + exit_code = Engine.run(["clean", "--force"]) + assert exit_code == 1 + end) + end) + + # Should only attempt dir1 and dir2, not dir3 + attempted_dirs = Agent.get(agent, & &1) |> Enum.reverse() + assert length(attempted_dirs) == 2 + assert Enum.at(attempted_dirs, 0) =~ "0.1.0" + assert Enum.at(attempted_dirs, 1) =~ "0.2.0" + end end describe "run/1 - clean subcommand interactive mode" do @@ -111,7 +150,8 @@ defmodule Expert.EngineTest do assert File.exists?(dir1) capture_io([input: "y\n"], fn -> - Engine.run(["clean"]) + exit_code = Engine.run(["clean"]) + assert exit_code == 0 end) refute File.exists?(dir1) @@ -122,7 +162,8 @@ defmodule Expert.EngineTest do File.mkdir_p!(dir1) capture_io([input: "yes\n"], fn -> - Engine.run(["clean"]) + exit_code = Engine.run(["clean"]) + assert exit_code == 0 end) refute File.exists?(dir1) @@ -133,7 +174,8 @@ defmodule Expert.EngineTest do File.mkdir_p!(dir1) capture_io([input: "\n"], fn -> - Engine.run(["clean"]) + exit_code = Engine.run(["clean"]) + assert exit_code == 0 end) refute File.exists?(dir1) @@ -144,7 +186,8 @@ defmodule Expert.EngineTest do File.mkdir_p!(dir1) capture_io([input: "n\n"], fn -> - Engine.run(["clean"]) + exit_code = Engine.run(["clean"]) + assert exit_code == 0 end) assert File.exists?(dir1) @@ -155,7 +198,8 @@ defmodule Expert.EngineTest do File.mkdir_p!(dir1) capture_io([input: "no\n"], fn -> - Engine.run(["clean"]) + exit_code = Engine.run(["clean"]) + assert exit_code == 0 end) assert File.exists?(dir1) @@ -166,7 +210,8 @@ defmodule Expert.EngineTest do File.mkdir_p!(dir1) capture_io([input: "maybe\n"], fn -> - Engine.run(["clean"]) + exit_code = Engine.run(["clean"]) + assert exit_code == 0 end) assert File.exists?(dir1) @@ -182,7 +227,8 @@ defmodule Expert.EngineTest do # Answer yes to first, no to second, yes to third capture_io([input: "y\nn\nyes\n"], fn -> - Engine.run(["clean"]) + exit_code = Engine.run(["clean"]) + assert exit_code == 0 end) refute File.exists?(dir1) @@ -193,18 +239,74 @@ defmodule Expert.EngineTest do test "prints message when no engine builds exist" do output = capture_io([input: "\n"], fn -> - Engine.run(["clean"]) + exit_code = Engine.run(["clean"]) + assert exit_code == 0 end) assert output =~ "No engine builds found." end + + test "stops on first deletion error in interactive mode and returns error code 1" do + dir1 = Path.join(@test_base_dir, "0.1.0") + File.mkdir_p!(dir1) + + patch(File, :rm_rf, fn _path -> + {:error, :eacces, dir1} + end) + + output = + capture_io(:stderr, fn -> + capture_io([input: "y\n"], fn -> + exit_code = Engine.run(["clean"]) + assert exit_code == 1 + end) + end) + + assert output =~ "Error deleting" + end + + test "stops deleting after first error in interactive mode" do + dir1 = Path.join(@test_base_dir, "0.1.0") + dir2 = Path.join(@test_base_dir, "0.2.0") + dir3 = Path.join(@test_base_dir, "0.3.0") + File.mkdir_p!(dir1) + File.mkdir_p!(dir2) + File.mkdir_p!(dir3) + + # Track which directories were attempted + {:ok, agent_pid} = Agent.start_link(fn -> [] end) + + # Fail on the second directory + patch(File, :rm_rf, fn path -> + :ok = Agent.update(agent_pid, fn list -> [path | list] end) + + cond do + String.ends_with?(path, "0.1.0") -> {:ok, []} + String.ends_with?(path, "0.2.0") -> {:error, :eacces, path} + true -> {:ok, []} + end + end) + + capture_io(:stderr, fn -> + capture_io([input: "y\ny\ny\n"], fn -> + exit_code = Engine.run(["clean"]) + assert exit_code == 1 + end) + end) + + attempted_dirs = Agent.get(agent, & &1) |> Enum.reverse() + assert length(attempted_dirs) == 2 + assert Enum.at(attempted_dirs, 0) =~ "0.1.0" + assert Enum.at(attempted_dirs, 1) =~ "0.2.0" + end end describe "run/1 - help and unknown commands" do test "prints help for unknown subcommand" do output = capture_io(fn -> - Engine.run(["unknown"]) + exit_code = Engine.run(["unknown"]) + assert exit_code == 0 end) assert output =~ "Expert Engine Management"