diff --git a/apps/expert/lib/expert/application.ex b/apps/expert/lib/expert/application.ex index fc9b81f1..b80f05b7 100644 --- a/apps/expert/lib/expert/application.ex +++ b/apps/expert/lib/expert/application.ex @@ -12,8 +12,21 @@ 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] -> + engine_args + |> Expert.Engine.run() + |> System.halt() + + _ -> + :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 +39,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 +47,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..8fffe31d --- /dev/null +++ b/apps/expert/lib/expert/engine.ex @@ -0,0 +1,224 @@ +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 the exit code for the command. Clean operations will stop at the + first deletion error and return exit code 1. + """ + + @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, + strict: [force: :boolean], + aliases: [f: :force] + ) + + 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() :: non_neg_integer() + 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 + + @success_code + end + + @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() + @success_code + + 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 + + @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 + result = + Enum.reduce_while(dirs, :ok, fn dir, _acc -> + case File.rm_rf(dir) do + {: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) + + case result do + :ok -> @success_code + :error -> @error_code + end + 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 + + @spec print_help() :: non_neg_integer() + 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 + + SUBCOMMANDS: + ls List all engine build directories + clean Interactively delete engine build directories + + 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 + """) + + @success_code + 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..c8db29c3 --- /dev/null +++ b/apps/expert/test/expert/engine_test.exs @@ -0,0 +1,315 @@ +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) + + 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 -> + exit_code = Engine.run(["ls"]) + assert exit_code == 0 + 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 -> + exit_code = Engine.run(["ls"]) + assert exit_code == 0 + 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 -> + exit_code = Engine.run(["clean", "--force"]) + assert exit_code == 0 + 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 -> + exit_code = Engine.run(["clean", "-f"]) + assert exit_code == 0 + end) + + refute File.exists?(dir1) + end + + 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) + + # 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 -> + 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 + 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 -> + exit_code = Engine.run(["clean"]) + assert exit_code == 0 + 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 -> + exit_code = Engine.run(["clean"]) + assert exit_code == 0 + 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 -> + exit_code = Engine.run(["clean"]) + assert exit_code == 0 + 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 -> + exit_code = Engine.run(["clean"]) + assert exit_code == 0 + 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 -> + exit_code = Engine.run(["clean"]) + assert exit_code == 0 + 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 -> + exit_code = Engine.run(["clean"]) + assert exit_code == 0 + 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 -> + exit_code = Engine.run(["clean"]) + assert exit_code == 0 + 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 -> + 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 -> + exit_code = Engine.run(["unknown"]) + assert exit_code == 0 + end) + + assert output =~ "Expert Engine Management" + end + end +end